diff --git a/.cargo/config.toml b/.cargo/config.toml index 35049cbcb..6c26ee582 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,9 @@ [alias] xtask = "run --package xtask --" + +# WebAssembly SIMD for the web build: without it vello_cpu's SIMD layer (fearless_simd) runs its +# scalar fallback on wasm32 and the canvas renders about 2.4x slower (#2786). The web build then +# needs Chrome/Edge 91+, Firefox 89+ or Safari 16.4+. A RUSTFLAGS environment variable replaces +# these flags. +[target.wasm32-unknown-unknown] +rustflags = ["-C", "target-feature=+simd128"] diff --git a/ASSETS.md b/ASSETS.md index 34ff73361..17ab680ff 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -368,6 +368,18 @@ Generated-in-code art is original and has no file to list. This covers the defau | `crates/engine/tests/fixtures/jpeg2000/checker.jp2` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `3de9c2bb5b8aaec241d939b795002a68eb3d50e41d51c43368000804ad3de057` | | `crates/engine/tests/fixtures/jpeg2000/checker-raw.j2k` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `76fa51ab8ccbc2534098b26b8e7fe16dd886e2b6298ba52d06a6b1bde9b61d9e` | | `crates/engine/tests/fixtures/jpeg2000/checker-300ppi.jp2` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `1b1aeed8975d3cdc832da6ecf5855852d07d0c490105121e61a8aefb31af009b` | +| `crates/engine/tests/fixtures/heif/quadrants.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 red, green, blue and white quadrants encoded with libheif's `heif-enc` 1.23.2 (x265); SHA-256 `122d14963fe899daa736cd216902cf4fc8f2192b3a0ba81f94004adcd71a0ff5` | +| `crates/engine/tests/fixtures/heif/quadrants-alpha.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants with a transparent right half, encoded with `heif-enc` 1.23.2; SHA-256 `55b5a537401ead742714c3a1bc526559ec8c1ba97cdb7f43ac63f4037167475b` | +| `crates/engine/tests/fixtures/heif/quadrants-10bit.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants at 10 bits, encoded with `heif-enc` 1.23.2; SHA-256 `11014848c6a838e5799d87e034eff1b06b816712ed70ba451bd582d72741774f` | +| `crates/engine/tests/fixtures/heif/quadrants-grid.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 128 × 128 quadrants as a 2 × 2 grid of 64 × 64 tiles, encoded with `heif-enc` 1.23.2; SHA-256 `5802f8715110954eb9c58d4bdcc10d1200426a48cf726f1b88dfb60da825ba7a` | +| `crates/engine/tests/fixtures/heif/quadrants-300ppi.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants with EXIF resolution tags of 300 ppi, encoded with `heif-enc` 1.23.2; SHA-256 `6a344a6de04fc5fceaee46b6e0028b12478c90bdcb6f6f14ed8c86a44cb203d7` | +| `crates/engine/tests/fixtures/heif/wide-rotated.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 16 red and blue halves stored with a quarter-turn rotation, encoded with `heif-enc` 1.23.2; SHA-256 `e3a2c3a9beb4066c13d6deb8e1311805c6aac485fde8784f1ee317dd95b89973` | +| `crates/engine/tests/fixtures/heif/wide-rotated-300x150.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the rotated halves with EXIF resolution tags of 300 ppi across and 150 ppi down, encoded with `heif-enc` 1.23.2; SHA-256 `79ad7945cd2c1ea9d3e70ac9b93d9a162fd8ce37541a0f3de44e12f04745921d` | +| `crates/engine/tests/fixtures/heif/tones.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 quadrants of four mid tones, encoded with `heif-enc` 1.23.2; SHA-256 `29ed7d75fbfbc4c298eb2da3a6b62625a150cd6bfe5b7ff1d847437501b1b4b1` | +| `crates/engine/tests/fixtures/heif/tones-p3-nclx.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones labeled Display P3 in an `nclx` color box, encoded with `heif-enc` 1.23.2; SHA-256 `baeb32ffa58ac1ac29a00c9e388b9281f4acb4695c4681e4933d0ad061447101` | +| `crates/engine/tests/fixtures/heif/tones-p3-icc.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones with VectorCraft's own Display P3 ICC profile (`vectorcraft_color::cms::icc_bytes`), encoded with `heif-enc` 1.23.2; SHA-256 `472e1a123b9515926a8985a04c3f680387e8c410dbf8f7ff0cfb8cb99c89e93b` | +| `crates/engine/tests/fixtures/heif/tones-p3-icc-only.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones with VectorCraft's own Display P3 ICC profile as their only color box, encoded with `heif-enc` 1.23.2; SHA-256 `2654fdd21e56641107db20b3f4d0a66a86420ac040d42ddf55e20f7e789d5c55` | +| `crates/engine/tests/fixtures/heif/quadrants-grid16.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 quadrants as a 2 × 2 grid of 16 × 16 tiles, each cropped from a 64 × 64 picture, encoded with `heif-enc` 1.23.2; SHA-256 `bd114ee66cdac10142729dc9dddfa1cd96993a5cefa54c2f25b65496652f007f` | ## Assets embedded by the PDF backend diff --git a/Cargo.lock b/Cargo.lock index 72d591b91..93ae3be0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1035,6 +1035,17 @@ dependencies = [ "winsafe", ] +[[package]] +name = "craft-proof" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" +dependencies = [ + "log", + "miniz_oxide 0.9.1", + "toml", + "unicode-normalization", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -1059,7 +1070,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" dependencies = [ "chrono", "dunce", @@ -1442,7 +1453,7 @@ dependencies = [ "itertools", "log", "profiling", - "resvg", + "resvg 0.45.1", ] [[package]] @@ -2286,6 +2297,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "heic-rs" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66" +dependencies = [ + "rayon", +] + [[package]] name = "hermit-abi" version = "0.5.3" @@ -2835,6 +2855,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" @@ -4429,11 +4625,29 @@ dependencies = [ "pico-args", "rgb", "svgtypes 0.15.3", - "tiny-skia", + "tiny-skia 0.11.4", "usvg 0.45.1", "zune-jpeg 0.4.21", ] +[[package]] +name = "resvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd" +dependencies = [ + "bytemuck", + "gif 0.14.2", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes 0.16.1", + "tiny-skia 0.12.0", + "usvg 0.48.1", + "zune-jpeg 0.5.15", +] + [[package]] name = "rfd" version = "0.17.2" @@ -4718,7 +4932,7 @@ checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" dependencies = [ "log", "smithay-client-toolkit 0.19.2", - "tiny-skia", + "tiny-skia 0.11.4", ] [[package]] @@ -5584,6 +5798,21 @@ dependencies = [ "tiny-skia-path 0.11.4", ] +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.18.1", + "tiny-skia-path 0.12.0", +] + [[package]] name = "tiny-skia-path" version = "0.11.4" @@ -6226,7 +6455,9 @@ version = "0.21.0" dependencies = [ "image", "kurbo 0.13.1", + "resvg 0.48.1", "serde_json", + "svgtypes 0.16.1", "vectorcraft-color", "vectorcraft-doc", "vectorcraft-geom", @@ -6243,13 +6474,19 @@ dependencies = [ "cfb", "chrono", "craft-docpath", + "craft-proof", "crc32fast", "flate2", "hayro-jpeg2000", + "heic-rs", "image", + "jxl-color", + "jxl-grid", + "jxl-oxide", "kurbo 0.13.1", "log", "proptest", + "rayon", "roxmltree 0.21.2", "rustix 1.1.6", "ruzstd", @@ -6277,6 +6514,8 @@ dependencies = [ "vectorcraft-tools", "vectorcraft-trace", "wat", + "zune-core 0.5.3", + "zune-jpegxl", ] [[package]] @@ -6284,6 +6523,7 @@ name = "vectorcraft-eps" version = "0.21.0" dependencies = [ "craft-pdf", + "encoding_rs", "fax", "flate2", "hayro-ccitt", @@ -6292,6 +6532,7 @@ dependencies = [ "memchr", "ruzstd", "serde_json", + "skrifa 0.47.0", "vectorcraft-brush", "vectorcraft-color", "vectorcraft-doc", @@ -6337,6 +6578,7 @@ dependencies = [ name = "vectorcraft-js" version = "0.21.0" dependencies = [ + "chrono", "proptest", "unicode-general-category", ] @@ -6422,6 +6664,8 @@ dependencies = [ name = "vectorcraft-plugins" version = "0.21.0" dependencies = [ + "craft-vectorcraft-format", + "image", "log", "serde", "serde_json", @@ -6480,7 +6724,7 @@ dependencies = [ "image", "kurbo 0.13.1", "proptest", - "resvg", + "resvg 0.45.1", "serde", "serde_json", "simplecss", @@ -7992,7 +8236,6 @@ dependencies = [ "nix", "typed-path", "windows-sys 0.61.2", - "zopfli", ] [[package]] @@ -8007,18 +8250,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" -[[package]] -name = "zopfli" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] - [[package]] name = "zune-core" version = "0.4.12" @@ -8049,6 +8280,15 @@ dependencies = [ "zune-core 0.5.3", ] +[[package]] +name = "zune-jpegxl" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cba11ecd07cc23351500e840ae03841b7e4997923ec8e4832ee3ae199ea0b6b4" +dependencies = [ + "zune-core 0.5.3", +] + [[package]] name = "zvariant" version = "5.15.1" diff --git a/Cargo.toml b/Cargo.toml index a3e3e9890..375b17a3e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,13 +16,14 @@ craft-print = { git = "https://github.com/storytold/craft-libs", rev = "9be9a637 craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["serde"] } craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } +craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } # Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per # desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.) craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["egui"] } # The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON. craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } vectorcraft-color = { path = "crates/color" } @@ -99,12 +100,25 @@ ruzstd = { version = "0.9.1", default-features = false, features = ["std"] } hayro-ccitt = "0.3" # Substring search for the EPS reader's PostScript `search`. memchr = "2" +# The WHATWG Encoding Standard's legacy encodings: names in legacy .ai files read in the code page +# the user picks (Mac OS Roman, Korean, Japanese, Chinese). +encoding_rs = "0.8.42" jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] } gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] } # CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG # 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" +# 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" # ui egui = "0.37" diff --git a/README.md b/README.md index d71e5b23a..dc7a1cfa5 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the fu (92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% ready for real work for a mainstream illustrator (~62% across all tracked features): 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 with each paragraph's indents, spacing and leading, tables (as text frames and lines), master @@ -317,7 +317,7 @@ Installers and executables are code-signed. | Build | File | Notes | |---|---|---| -| Static site | `vectorcraft-web-.zip` | Runs in a modern browser; host it on any static server | +| Static site | `vectorcraft-web-.zip` | Runs in a browser with WebAssembly SIMD (Chrome/Edge 91, Firefox 89, Safari 16.4 or newer), see `packaging/web/README.md`; host it on any static server | ## The Crafting Apps diff --git a/ROADMAP.md b/ROADMAP.md index 0f9267e0e..1bedf0b95 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,6 +4,7 @@ - Compound Shapes add-on (beyond Illustrator): a menu for Make, Release and Expand Compound Shape and a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons create compound shapes on a click. Scores and estimates unchanged. - Paint: new gradients use the document's color model for default stops, including Gradient tool creation. Scores and estimates unchanged. +- UI: Window → Help Bar toggles the tool-help strip separately from the status bar and saves visibility with workspaces. Scores and estimates unchanged. - PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document. - PDF.W1: PDF export and print composition use the shared optional writer for configuration, metadata, font preparation and guarded finalization; drawing and output policy remain app-owned. @@ -14,7 +15,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -34,6 +35,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)). | | Value | Kind | |---|---|---| +| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. | | **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 57/57, 3D 4/4 (initial types, no materials) | | **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated | | **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated | @@ -134,6 +136,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log +- 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged. - 2026-11-05: Release Type on a Path joins the Type Tools add-on (Object › Plug-ins › Type Tools): each selected type on a path splits into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on, with its exact shape and place in the stacking order and no paint; or keeps only the path or only the text, optionally leaving the original. A dialog with Preview, one undo step (`addon.type.releasePath`, #5532). A VectorCraft addition: headline numbers unchanged. - 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged. - 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. @@ -223,6 +226,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates 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 | | 2026-11-05 | minor | Progress log row for the Type Tools add-on's Release Type on a Path (#5532); headline numbers unchanged. | | 2026-11-03 | minor | Progress log row for the Type Tools add-on's Text Tweaker (#4924); headline numbers unchanged. | diff --git a/apps/vectorcraft-cli/Cargo.toml b/apps/vectorcraft-cli/Cargo.toml index f7faf7a92..376f43190 100644 --- a/apps/vectorcraft-cli/Cargo.toml +++ b/apps/vectorcraft-cli/Cargo.toml @@ -16,7 +16,7 @@ workspace = true [dependencies] vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true } -vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] } +vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] } vectorcraft-format = { workspace = true } vectorcraft-geom = { workspace = true } vectorcraft-svg = { workspace = true } diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 2d8df4592..126d361c5 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -1,10 +1,11 @@ //! `vectorcraft-cli`: VectorCraft from the command line. //! //! ```text +//! vectorcraft-cli --safe-mode SUBCOMMAND ... //! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]... //! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2] [--ppi 300] //! vectorcraft-cli commands -//! vectorcraft-cli convert IN OUT [--scale 2] [--ppi 300] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] +//! vectorcraft-cli convert IN OUT [--scale 2] [--ppi 300] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--legacy-name-encoding ENC] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] //! vectorcraft-cli info FILE //! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5] //! vectorcraft-cli perf [--paths 50000] [--deep] @@ -72,7 +73,12 @@ const USAGE: &str = "\ vectorcraft-cli — VectorCraft automation USAGE: - vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]... + vectorcraft-cli --safe-mode SUBCOMMAND ... + Run any subcommand without plug-ins: none loads or runs, and plug-in commands report that + Safe Mode is on. Plug-ins stay installed. `--safe-mode mcp` serves a headless session and + does not take --connect. + + vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]... [--allow-plugin-grants] Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979 (vectorcraft --control 7979), falling back to a headless in-process session. --automation-read-root / --automation-write-root confine the files the agent's commands read @@ -80,8 +86,18 @@ USAGE: DIR, links followed; each may be given more than once, a path inside any of its folders being allowed, and a kind left out grants none of its access. They imply --headless: a running app enforces its own (vectorcraft --control PORT --automation-read-root DIR …). + --allow-plugin-grants lets plugin.install's grant and allowHeavy take effect in this session, + so whoever sends the commands can give a plug-in the permissions it lists, and a heavy budget, + with nobody approving them. Without it they are refused, and a plug-in that lists permissions + installs only when its hash is already recorded in grants.json, by a person's approval in the + desktop app or by a grant accepted under this flag. A grant accepted under this flag stays + recorded and counts as approved in later sessions. It implies --headless. It can't be used + with --automation-read-root or --automation-write-root: a server confined to folders has no + plug-in store, so it reads no grants.json, and a plug-in that lists permissions or asks for a + heavy budget does not install there. With VECTORCRAFT_NO_PREFS set there is no store either, + and a grant accepted under this flag lasts for that session only. - vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N] [--ppi N] + vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N] [--ppi N] [--allow-plugin-grants] Headless batch: open FILE (any readable format) or start a new document, run commands in order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE is a template, written as file.saveAsTemplate writes it). Raster exports use the document's @@ -91,11 +107,13 @@ USAGE: --cmd and --export steps: a JSON array of {\"command\": ID, \"params\": {...}} (or \"id\" for \"command\"), or one such object per line (JSON Lines), at most 64 MiB and 100000 steps. Errors name the step, counting from 0 as \"$N\" references do. + --allow-plugin-grants lets plugin.install's grant and allowHeavy take effect in this run, as + for mcp. vectorcraft-cli commands Print the command catalogue as JSON. - vectorcraft-cli convert IN OUT [--scale N] [--ppi N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] + vectorcraft-cli convert IN OUT [--scale N] [--ppi N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--legacy-name-encoding ENC] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] Open IN (any readable format) and export OUT in the format its extension picks (see Writable formats). Raster resolution: --ppi, else 72 times --scale, else the document's raster effects resolution; other formats leave them out. --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard @@ -108,7 +126,10 @@ USAGE: IN left things out (hidden text, art or layers it could not read; the notes say which) unless --replace-lossy is given. --editing-data opens a .ai or an EPS with editing data from its editing copy even where that draws differently from its PDF part or page (which can be older), as long as - it can be read. A PDF OUT omits the embedded native document unless --preserve-editing is + it can be read. --legacy-name-encoding reads the names in a .ai's or an EPS's editing data + that aren't UTF-8 (layers, objects, artboards, colors, gradients, patterns, graphic styles, + swatches, brushes, symbols, variables and data sets, and data sets' text values) in + windows-1252 (the default), macintosh, windows-949, shift_jis, gbk or big5. A PDF OUT omits the embedded native document unless --preserve-editing is given (the app's Save As PDF still defaults to keeping it). With --preserve-editing, when the attachment is written the result's warnings say so (it is left out, with its own warning, for --artboard or a --range without every artboard). @@ -128,12 +149,17 @@ USAGE: Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame (warm), multithreaded and single-threaded. - vectorcraft-cli perf [--paths N] [--deep] + vectorcraft-cli perf [--paths N] [--deep] [--native-io [--artboards 1..4]] Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder, handle and segment drags) on a synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded, unless the report says the machine is busy. --deep also times work with no budget yet and prints memory use (the working set on Windows), with the peak so far on Windows and Linux. + --native-io instead measures the application's native save/open commands (one warmup, + five samples, no disk I/O), including preview, PDF, compression and API base64 costs. + It reports the default and explicit PDF opt-out, with and without preview/compression; + --artboards repeats the same artwork + on 1 to 4 overlapping artboards. This mode allows at most 50000 paths. "; /// The usage text plus the formats `document.open` reads and `document.export` writes. @@ -175,12 +201,18 @@ fn main() -> ExitCode { #[cfg(target_os = "macos")] mac_fonts::install(); prefs_dir::install_fonts(); - let args: Vec = std::env::args().skip(1).collect(); + let mut args: Vec = std::env::args().skip(1).collect(); + // Before any subcommand attaches the plug-in folder. + let safe_mode = args.first().is_some_and(|a| a == "--safe-mode"); + if safe_mode { + args.remove(0); + vectorcraft_engine::cmd::plugin::start_in_safe_mode(); + } let r = match args.first().map(String::as_str) { // `commands` and `info` take no options, so `--help` or `-h` anywhere after them prints the // usage. The other subcommands check for it themselves. Some("commands" | "info") if help_requested(&args[1..], &[]) => help(), - Some("mcp") => mcp(&args[1..]), + Some("mcp") => mcp(&args[1..], safe_mode), Some("run") => run(&args[1..]), Some("commands") => commands(), Some("convert") => convert(&args[1..]), @@ -213,12 +245,23 @@ fn with_stores(mut h: Headless) -> Headless { h } -fn mcp(args: &[String]) -> Result<(), String> { +/// Makes `h`'s `plugin.install` accept `grant` and `allowHeavy` when `allow` (`--allow-plugin-grants`). +fn accepting_grants(mut h: Headless, allow: bool) -> Headless { + if allow { + h.session.allow_plugin_grants_from_requests(); + } + h +} + +/// `mcp`. With `--safe-mode` (`safe_mode`) it serves a headless session: a running app loads its own +/// plug-ins, so `--connect` is refused and the default does not connect. +fn mcp(args: &[String], safe_mode: bool) -> Result<(), String> { if help_requested(args, &["--connect", "--automation-read-root", "--automation-write-root"]) { return help(); } let mut connect: Option = None; let mut headless = false; + let mut allow_grants = false; let (mut read_roots, mut write_roots): (Vec, Vec) = (vec![], vec![]); let mut it = args.iter(); while let Some(a) = it.next() { @@ -230,6 +273,7 @@ fn mcp(args: &[String]) -> Result<(), String> { match flag { "--connect" => connect = Some(it.next().cloned().ok_or("--connect needs an address")?), "--headless" => headless = true, + "--allow-plugin-grants" => allow_grants = true, "--automation-read-root" | "--automation-write-root" => { let dir = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("{flag} needs a folder"))?; if flag == "--automation-read-root" { read_roots.push(dir) } else { write_roots.push(dir) } @@ -240,6 +284,15 @@ fn mcp(args: &[String]) -> Result<(), String> { if headless && connect.is_some() { return Err("use either --connect or --headless".into()); } + if allow_grants && connect.is_some() { + return Err("--allow-plugin-grants applies to the headless server; in a running app a person approves each plug-in".into()); + } + if allow_grants && !(read_roots.is_empty() && write_roots.is_empty()) { + return Err("--allow-plugin-grants can't be used with --automation-read-root or --automation-write-root: a server confined to folders has no plug-in store to record a grant in".into()); + } + if safe_mode && connect.is_some() { + return Err("--safe-mode runs a headless session without plug-ins; a running app loads its own: start it with `vectorcraft --safe-mode --control `".into()); + } let read: Vec<&Path> = read_roots.iter().map(Path::new).collect(); let write: Vec<&Path> = write_roots.iter().map(Path::new).collect(); let roots = AutomationRoots::new(&read, &write)?; @@ -251,9 +304,9 @@ fn mcp(args: &[String]) -> Result<(), String> { // Every thread of this process, not only the server's: nothing reaches past the roots. file_access::confine_process(roots.clone()); eprintln!("vectorcraft-cli: files confined: {}", roots.describe()); - Box::new(Headless::with_document().with_automation_roots(Some(roots))) - } else if headless { - Box::new(with_stores(Headless::with_document())) + Box::new(accepting_grants(Headless::with_document().with_automation_roots(Some(roots)), allow_grants)) + } else if headless || safe_mode || allow_grants { + Box::new(accepting_grants(with_stores(Headless::with_document()), allow_grants)) } else if let Some(addr) = connect { // Explicit address: fail loudly if the app isn't there. Box::new(Remote::connect(&addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?) @@ -280,8 +333,20 @@ fn commands() -> Result<(), String> { } fn convert(args: &[String]) -> Result<(), String> { - let valued = - ["--scale", "-s", "--ppi", "--artboard", "-a", "--range", "-r", "--decimals", "--ai-version", "--hidden-objects", "--transparent-images"]; + let valued = [ + "--scale", + "-s", + "--ppi", + "--artboard", + "-a", + "--range", + "-r", + "--decimals", + "--ai-version", + "--hidden-objects", + "--transparent-images", + "--legacy-name-encoding", + ]; if help_requested(args, &valued) { return help(); } @@ -290,6 +355,9 @@ fn convert(args: &[String]) -> Result<(), String> { (None::, None::, None::, false, false, false, false); let mut ppi = None::; let mut decimals = None::; + // The encoding of a legacy `.ai`'s names that aren't UTF-8, passed on as `document.open`'s + // `legacyNameEncoding`; the engine checks it. + let mut legacy_names = None::; // Legacy `.ai` options, passed on as the export's own (`aiVersion`, `hiddenObjects`, // `transparentImages`); the engine checks their values. let mut legacy: Vec<(&str, String)> = vec![]; @@ -312,6 +380,9 @@ fn convert(args: &[String]) -> Result<(), String> { "--replace-lossy" => replace_lossy = true, "--editing-data" => editing_data = true, "--preserve-editing" => preserve_editing = true, + "--legacy-name-encoding" => { + legacy_names = Some(it.next().cloned().ok_or("--legacy-name-encoding needs an encoding such as windows-949")?); + } "--ai-version" => legacy.push(("aiVersion", it.next().cloned().ok_or("--ai-version needs 7 or pdf")?)), "--hidden-objects" => legacy.push(("hiddenObjects", it.next().cloned().ok_or("--hidden-objects needs layer, omit or visible")?)), "--transparent-images" => legacy.push(("transparentImages", it.next().cloned().ok_or("--transparent-images needs flatten or omit")?)), @@ -324,8 +395,14 @@ fn convert(args: &[String]) -> Result<(), String> { return Err("a .ait template holds every artboard: --artboard and --range don't apply".into()); } let mut h = Headless::new(); - let opened = if editing_data { - let params = json!({"path": input, "editingData": "always"}); + let opened = if editing_data || legacy_names.is_some() { + let mut params = json!({"path": input}); + if editing_data { + params["editingData"] = json!("always"); + } + if let Some(e) = &legacy_names { + params["legacyNameEncoding"] = json!(e); + } h.call("engine.execute", json!({"command": "document.open", "params": params})).map_err(|e| format!("open {input}: {e}"))? } else { h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))? @@ -512,6 +589,7 @@ fn run(args: &[String]) -> Result<(), String> { let mut stdin_read = false; let mut scale = None::; let mut ppi = None::; + let mut allow_grants = false; let mut it = args.iter(); while let Some(a) = it.next() { let mut val = |what: &str| it.next().cloned().ok_or_else(|| format!("{a} needs {what}")); @@ -546,6 +624,7 @@ fn run(args: &[String]) -> Result<(), String> { steps.push(Step::Export(val("a file")?)); params_for = None; } + "--allow-plugin-grants" => allow_grants = true, "--scale" | "-s" => { let raw = val("a number")?; scale = Some(raw.parse::().map_err(|_| format!("--scale {raw}: not a number"))?); @@ -563,7 +642,7 @@ fn run(args: &[String]) -> Result<(), String> { } } - let mut h = with_stores(Headless::new()); + let mut h = accepting_grants(with_stores(Headless::new()), allow_grants); let mut out = std::io::stdout().lock(); // A reader that stopped early (a closed pipe) isn't an error, and the batch goes on: its later // steps (an export…) still run, unprinted. diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index 3ff82512d..e533d00b7 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -64,6 +64,61 @@ fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 { v.get(v.len() / 2).copied().unwrap_or(0.0) } +/// Exercise the application's native save/open commands, including previews, PDF output, +/// compression and API base64 conversion. Session setup and validation are outside timing. +/// Five measured runs follow one warmup; no filesystem or GPU window is involved. +fn native_io(doc: &Document) -> Result<(), String> { + for (label, compatible, preview) in [ + ("default options", None, false), + ("PDF disabled", Some(false), false), + ("preview+gzip", None, true), + ("preview+gzip, PDF disabled", Some(false), true), + ] { + let mut session = vectorcraft_engine::Session::new(); + session.add_document(doc.clone(), None); + let mut params = json!({"format":"vectorcraft","modified":null,"preview":preview,"compress":preview}); + if let Some(value) = compatible { + params["pdfCompatible"] = json!(value); + } + let (mut saves, mut opens) = (Vec::new(), Vec::new()); + let (mut size, mut has_pdf, mut compressed) = (0, false, false); + for run in 0..6 { + let start = Instant::now(); + let saved = session.execute("file.saveAs", ¶ms).map_err(|e| e.to_string())?; + let save_ms = start.elapsed().as_secs_f64() * 1000.0; + let data = saved["dataBase64"].as_str().ok_or("native save returned no dataBase64")?; + let bytes = vectorcraft_format::base64_decode(data).ok_or("native save returned invalid base64")?; + size = bytes.len(); + has_pdf = vectorcraft_format::pdf_content(&bytes).is_some(); + if compatible == Some(false) && has_pdf { + return Err("native save ignored the explicit PDF opt-out".into()); + } + compressed = vectorcraft_format::is_compressed(&bytes); + if compressed != preview || vectorcraft_format::preview(&bytes).is_some() != preview { + return Err("native save did not apply the requested preview/compression options".into()); + } + let args = json!({"name":"perf.vectorcraft","dataBase64":data}); + let mut opened = vectorcraft_engine::Session::new(); + let start = Instant::now(); + opened.execute("document.open", &args).map_err(|e| e.to_string())?; + let open_ms = start.elapsed().as_secs_f64() * 1000.0; + if opened.doc().map_err(|e| e.to_string())?.doc.artboards.len() != doc.artboards.len() { + return Err("native round trip changed the artboard count".into()); + } + if run > 0 { + saves.push(save_ms); + opens.push(open_ms); + } + } + saves.sort_by(f64::total_cmp); + opens.sort_by(f64::total_cmp); + let save = saves.get(2).copied().ok_or("no save measurements")?; + let open = opens.get(2).copied().ok_or("no open measurements")?; + outln!(" native {label}: save {save:.2} ms, open {open:.2} ms, {size} bytes, PDF {has_pdf}, compressed {compressed}"); + } + Ok(()) +} + /// 1-minute load average (macOS / Linux), if available. #[cfg(not(windows))] fn load_average() -> Option { @@ -393,19 +448,36 @@ fn deep(n: usize) -> Result<(), String> { } pub fn run(args: &[String]) -> Result<(), String> { - if crate::help_requested(args, &["--paths"]) { + if crate::help_requested(args, &["--paths", "--artboards"]) { return crate::help(); } let mut n = 50_000usize; let mut want_deep = false; + let mut want_native = false; + let mut artboards = None::; let mut it = args.iter(); while let Some(a) = it.next() { match a.as_str() { "--deep" => want_deep = true, + "--native-io" => want_native = true, + "--artboards" => { + artboards = + Some(it.next().and_then(|v| v.parse().ok()).filter(|n| (1..=4).contains(n)).ok_or("--artboards needs a number from 1 to 4")?) + } "--paths" => n = it.next().and_then(|v| v.parse().ok()).ok_or("--paths needs a number")?, other => return Err(format!("unknown perf option `{other}`")), } } + if want_native && n > 50_000 { + return Err("--native-io allows at most 50000 paths".into()); + } + if want_native && want_deep { + return Err("--native-io and --deep are separate measurement modes".into()); + } + if artboards.is_some() && !want_native { + return Err("--artboards requires --native-io".into()); + } + let artboards = artboards.unwrap_or(1); let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); // The figure is the 1-minute load average, or on Windows the cores busy over one second. #[cfg(not(windows))] @@ -418,8 +490,20 @@ pub fn run(args: &[String]) -> Result<(), String> { outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy."); } let t = Instant::now(); - let doc = synthetic(n)?; + let mut doc = synthetic(n)?; outln!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0); + if want_native { + let first = doc.artboards.first().cloned().ok_or("synthetic document has no artboard")?; + // Overlapping artboards intentionally repeat the same paths on every PDF page. + for index in 1..artboards { + let mut page = first.clone(); + page.id = first.id.checked_add(index as u32).ok_or("artboard id overflow")?; + page.name = format!("Perf {}", index + 1); + doc.artboards.push(page); + } + outln!(" application native I/O: {artboards} overlapping artboard(s), one warmup + five samples; medians; no disk I/O"); + return native_io(&doc); + } let mut rows: Vec<(&str, f64, f64)> = vec![]; let opts = RenderOptions::default(); @@ -513,6 +597,24 @@ pub fn run(args: &[String]) -> Result<(), String> { mod tests { use super::*; + #[test] + fn native_io_exercises_the_application_save_and_open_commands() { + native_io(&synthetic(3).unwrap()).unwrap(); + } + + #[test] + fn native_io_rejects_conflicting_modes_and_excessive_workloads() { + for (args, expected) in [ + (vec!["--native-io", "--deep"], "separate measurement modes"), + (vec!["--native-io", "--paths", "50001"], "at most 50000"), + (vec!["--native-io", "--artboards", "5"], "1 to 4"), + (vec!["--artboards", "1"], "requires --native-io"), + (vec!["--artboards", "2"], "requires --native-io"), + ] { + assert!(run(&args.into_iter().map(str::to_owned).collect::>()).unwrap_err().contains(expected)); + } + } + #[test] fn busy_share_leaves_out_idle_time() { // In one second four processors log 40,000,000 ticks of 100 ns of kernel and user time, diff --git a/apps/vectorcraft-cli/tests/it/cli.rs b/apps/vectorcraft-cli/tests/it/cli.rs index 9caf4bc92..f2600d9b4 100644 --- a/apps/vectorcraft-cli/tests/it/cli.rs +++ b/apps/vectorcraft-cli/tests/it/cli.rs @@ -19,6 +19,77 @@ fn empty_dir(dir: std::path::PathBuf) -> std::path::PathBuf { dir } +/// `run` and `mcp --headless` refuse `plugin.install` with this param unless the process was started +/// with `--allow-plugin-grants`. +fn approval_param_needs_the_start_up_flag(key: &str, value: Value) { + let module = vectorcraft_format::base64_encode(include_bytes!("../../../../crates/plugins/tests/fixtures/desaturate.wasm")); + let steps = tmp(&format!("{key}.json")); + std::fs::write(&steps, json!([{"command": "plugin.install", "params": {"dataBase64": module, key: value}}]).to_string()).unwrap(); + let config = empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-{key}-{}", std::process::id()))); + let run = |extra: &[&str]| { + Command::new(BIN).env("VECTORCRAFT_CONFIG_DIR", &config).args(["run", "--commands", steps.to_str().unwrap()]).args(extra).output().unwrap() + }; + let out = run(&[]); + assert!(!out.status.success()); + let shown = String::from_utf8_lossy(&out.stderr).into_owned() + &String::from_utf8_lossy(&out.stdout); + assert!(shown.contains("--allow-plugin-grants"), "{shown}"); + let out = run(&["--allow-plugin-grants"]); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + + let mcp_config = empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-mcp-{key}-{}", std::process::id()))); + let mcp_install = |extra: &[&str]| -> Value { + let mut child = Command::new(BIN) + .env("VECTORCRAFT_CONFIG_DIR", &mcp_config) + .args(["mcp", "--headless"]) + .args(extra) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + { + let mut stdin = child.stdin.take().unwrap(); + let params = json!({"dataBase64": module, key: value}); + for m in [ + json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}), + json!({"jsonrpc":"2.0","method":"notifications/initialized"}), + json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.install","params":params}}}), + ] { + writeln!(stdin, "{m}").unwrap(); + } + } + let out = child.wait_with_output().unwrap(); + assert!(out.status.success()); + let replies: Vec = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + replies.into_iter().find(|r| r["id"] == 2).unwrap()["result"].take() + }; + let refused = mcp_install(&[]); + assert_eq!(refused["isError"], true, "without the flag: {refused}"); + assert!(refused["content"][0]["text"].as_str().unwrap().contains("--allow-plugin-grants"), "{refused}"); + let accepted = mcp_install(&["--allow-plugin-grants"]); + assert_eq!(accepted["isError"], false, "with the flag: {accepted}"); +} + +/// `run` and `mcp --headless` refuse `plugin.install`'s `grant` and `allowHeavy` unless the process was +/// started with `--allow-plugin-grants`, which `mcp` won't take with `--connect` or the automation roots. +#[test] +fn plugin_grants_need_the_start_up_flag() { + approval_param_needs_the_start_up_flag("grant", json!(["doc.read"])); + approval_param_needs_the_start_up_flag("allowHeavy", json!(true)); + + let out = Command::new(BIN).args(["mcp", "--allow-plugin-grants", "--connect", "127.0.0.1:1"]).output().unwrap(); + assert!(!out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("--allow-plugin-grants applies to the headless server")); + let root = std::env::temp_dir(); + for flag in ["--automation-read-root", "--automation-write-root"] { + let out = Command::new(BIN).args(["mcp", "--allow-plugin-grants", flag, root.to_str().unwrap()]).stdin(Stdio::null()).output().unwrap(); + assert!(!out.status.success(), "{flag}"); + assert!(String::from_utf8_lossy(&out.stderr).contains("--allow-plugin-grants can't be used with"), "{flag}"); + } + let usage = Command::new(BIN).arg("help").output().unwrap(); + assert!(String::from_utf8_lossy(&usage.stdout).contains("--allow-plugin-grants lets plugin.install's grant and allowHeavy take effect")); +} + #[test] fn commands_prints_catalogue() { let out = Command::new(BIN).arg("commands").output().unwrap(); @@ -283,6 +354,22 @@ fn mcp_headless_over_stdio() { assert_eq!(replies[3]["result"]["content"][0]["type"], "image"); } +/// `--safe-mode mcp` serves a headless session without plug-ins: it refuses `--connect`, and by +/// default it does not connect to an app listening on the default address, which loads its own +/// plug-ins. +#[test] +fn safe_mode_mcp_does_not_connect_to_a_running_app() { + let out = Command::new(BIN).args(["--safe-mode", "mcp", "--connect", "127.0.0.1:9"]).env("VECTORCRAFT_NO_PREFS", "1").output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success() && err.contains("--safe-mode runs a headless session"), "{err}"); + // Something listening on the default address, as a running app does; a running app or another + // program already holding it serves as well. + let _app = std::net::TcpListener::bind(vectorcraft_mcp::DEFAULT_ADDR); + let out = Command::new(BIN).args(["--safe-mode", "mcp"]).env("VECTORCRAFT_NO_PREFS", "1").stdin(Stdio::null()).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success() && err.contains("MCP server on stdio (headless)"), "{err}"); +} + /// `mcp --automation-read-root` / `--automation-write-root` (#832): the flags are checked before the /// server starts, and the process then reads and writes only inside its roots. #[test] diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml index ff4bcef86..6e4dbd7f0 100644 --- a/apps/vectorcraft-web/Cargo.toml +++ b/apps/vectorcraft-web/Cargo.toml @@ -14,7 +14,7 @@ name = "vectorcraft-web" path = "src/main.rs" [dependencies] -vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] } +vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] } vectorcraft-ui-egui = { workspace = true } # The page command API (storytold/craft-libs#413): the request plumbing behind window.vectorcraft. craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 170a7e5c7..4ab382d5c 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -15,7 +15,7 @@ wgpu = ["eframe/wgpu", "dep:winsafe"] accessibility = ["eframe/accesskit"] [dependencies] -vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] } +vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] } vectorcraft-ui-egui = { workspace = true } vectorcraft-text = { workspace = true } # Shared across the Craft suite: decides who draws the window frame per desktop (compositor vs app) diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 3d9fef45c..f47b10e68 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -8,6 +8,7 @@ use super::windows_stylus; use super::{App, VectorcraftApp, file_access, window}; #[cfg(target_os = "macos")] use super::{mac_menu, mac_window, open_documents}; +use vectorcraft_ui_egui::dialogs::safe_mode; /// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating /// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel @@ -91,6 +92,9 @@ impl eframe::App for App { } fn on_exit(&mut self) { file_access::unconfined(|| save_prefs(&self.app)); + // A ready update installs once the process has exited. The macOS exit below skips + // dropping the app, so the helper is started here. + self.app.start_pending_update(); end_before_teardown(self.frames); } } @@ -113,16 +117,26 @@ impl App { vectorcraft_ui_egui::background::wait_all(&mut self.app); return; } + // An app that stays in Safe Mode leaves later launches to start their own app with plug-ins. + #[cfg(not(target_os = "macos"))] + if safe_mode::staying_on(&self.app) { + single_instance::resign(); + } // Files from the Finder and the Dock (macOS) or from later launches (#4892) wait while a file - // dialog is open: what asked for it runs again on its document when it answers. - if !self.app.file_dialog_open() { + // dialog is open: what asked for it runs again on its document when it answers. They also + // wait for the choice about a stopped plug-in, so they open after the plug-ins it loads, as + // do the command line's files held back for it. Files later launches handed over before the + // choice ended in Safe Mode open in a new app with plug-ins. + if !self.app.file_dialog_open() && !safe_mode::choosing(&self.app) { + let held = std::mem::take(&mut self.held_files); #[cfg(target_os = "macos")] let files = open_documents::take(); #[cfg(not(target_os = "macos"))] - let files = single_instance::take(); + let files = single_instance::take_for_frame(safe_mode::staying_on(&self.app), single_instance::launch_with_plug_ins); if !files.is_empty() { bring_window_forward(ctx); } + open_files(&mut self.app, held); open_files(&mut self.app, files); } let (now, zoom) = (ctx.input(|i| i.time), ctx.zoom_factor()); @@ -151,13 +165,15 @@ impl App { /// On macOS with accessibility, end the process once the app has saved what it keeps on quitting, /// before eframe tears the window down (#661). AccessKit then gives the window's content view back /// its own class, which AppKit's Touch Bar support was observing it under, and AppKit aborts the -/// app as it quits on Macs with a Touch Bar. Nothing after the window is needed on a normal quit. +/// app as it quits on Macs with a Touch Bar. On a normal quit, only the launch marker is needed +/// after the window, so it is ended here as `main` ends it after the window on other platforms. /// Not while the window is still starting up (`frames` below [`gpu::STARTUP_FRAMES`]): a graphics /// failure then starts the app again on another adapter (#651). #[allow(unused_variables)] fn end_before_teardown(frames: u64) { #[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))] if frames >= gpu::STARTUP_FRAMES { + safe_mode::end_launch(); log::info!("quitting"); log::logger().flush(); std::process::exit(0); @@ -172,6 +188,16 @@ pub(crate) fn bring_window_forward(ctx: &egui::Context) { ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } +/// Open the command line's `files` now, or, while the choice about a stopped plug-in is up +/// ([`safe_mode::choosing`]), return them to open once it is made, with the plug-ins it loads. +pub(crate) fn open_or_hold(app: &mut VectorcraftApp, files: Vec) -> Vec { + if safe_mode::choosing(app) { + return files; + } + open_files(app, files); + Vec::new() +} + /// Open files handed to the app (command line, macOS Finder and Dock, later launches) as documents. A file that /// can't be opened is reported to the user and on stderr; the others still open. pub(crate) fn open_files(app: &mut VectorcraftApp, files: Vec) { diff --git a/apps/vectorcraft/src/gpu.rs b/apps/vectorcraft/src/gpu.rs index d7d917ed4..0bf4732ed 100644 --- a/apps/vectorcraft/src/gpu.rs +++ b/apps/vectorcraft/src/gpu.rs @@ -310,7 +310,7 @@ fn pci_ids(device_id: &str) -> Option<(u32, u32)> { let field = |key: &str| { device_id.split(['\\', '&']).find_map(|part| { let (name, hex) = part.split_at_checked(key.len())?; - if name.eq_ignore_ascii_case(key) { u32::from_str_radix(hex, 16).ok() } else { None } + if name.eq_ignore_ascii_case(key) && hex.bytes().all(|b| b.is_ascii_hexdigit()) { u32::from_str_radix(hex, 16).ok() } else { None } }) }; Some((field("VEN_")?, field("DEV_")?)) diff --git a/apps/vectorcraft/src/gpu/tests.rs b/apps/vectorcraft/src/gpu/tests.rs index 40e6a555c..f424280b3 100644 --- a/apps/vectorcraft/src/gpu/tests.rs +++ b/apps/vectorcraft/src/gpu/tests.rs @@ -820,3 +820,10 @@ fn crashes_are_forgotten_in_another_version_or_under_another_preference() { assert_eq!(record.crashed.len(), CRASHED_LIMIT); assert_eq!(record.crashed.last().map(|c| c.key.as_str()), Some("Dx12:10de:0013")); } + +/// `u32::from_str_radix` accepts a leading `+`; a device id with one is not a PCI id. +#[test] +fn pci_ids_reject_a_sign_in_the_hex_digits() { + assert_eq!(pci_ids("PCI\\VEN_+10DE&DEV_2204"), None); + assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_+2204"), None); +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 8019276ec..fd062e504 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -1,7 +1,10 @@ //! VectorCraft desktop app. //! //! Usage: `vectorcraft [--control [--automation-read-root ]... [--automation-write-root ]...] -//! [--in-window-menus] [--new-instance] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`) +//! [--in-window-menus] [--new-instance] [--safe-mode] [--] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`) +//! +//! Any other argument starting with '-' (not `-` alone) is an error; after `--`, every argument is +//! a file. //! //! Files given while VectorCraft already runs open in that window, unless `--new-instance` is given //! (Windows and Linux; `single_instance`). @@ -65,6 +68,7 @@ mod xwayland; use vectorcraft_engine::Session; use vectorcraft_engine::cmd::fileio; use vectorcraft_engine::file_access::{self, AutomationRoots}; +use vectorcraft_ui_egui::dialogs::safe_mode; use vectorcraft_ui_egui::graphics::GraphicsLoss; use vectorcraft_ui_egui::picks::PickRequest; use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp}; @@ -77,7 +81,7 @@ mod titlebar; #[cfg(all(test, feature = "wgpu"))] use app::bring_window_forward; use app::native_input_hook; -use app::open_files; +use app::open_or_hold; use host_services::services; #[cfg(all(test, feature = "wgpu"))] use prefs::{KEEP_UI_PREFS, decode_saved_prefs, keep_prefs_copy, prefs_bytes}; @@ -92,7 +96,9 @@ use titlebar::custom_titlebar_from; use titlebar::opted_into_system_title_bar; #[cfg(windows)] use titlebar::set_taskbar_icon; -use titlebar::{custom_titlebar, native_options}; +#[cfg(all(test, feature = "wgpu"))] +use titlebar::{app_draws_frame, opted_into_app_title_bar, start_frame, system_title_bar_applies}; +use titlebar::{custom_titlebar, native_options, titlebar_from_env}; struct App { app: VectorcraftApp, @@ -111,6 +117,8 @@ struct App { pending_maximize: bool, /// Frames left to retry [`window::apply_pending_maximize`]. maximize_tries: u8, + /// The command line's files, held back until the choice about a stopped plug-in is made. + held_files: Vec, /// The time and UI zoom of the last monitor list ([`window::monitors_due`]). monitors_listed: Option<(f64, f32)>, } @@ -132,13 +140,89 @@ Options: (or VECTORCRAFT_IN_WINDOW_MENUS=1) --new-instance Open the files in a new window even when VectorCraft already runs (Windows and Linux) + --safe-mode Start without plug-ins: none loads or runs until + VectorCraft starts normally (Help › Safe Mode); the + files open in a new window, as with --new-instance --version Print the version and exit -h, --help Print this help and exit + -- Treat every argument after it as a file + +Any other argument starting with '-' is an error. Without a window (converting, scripting, the MCP server), use vectorcraft-cli: vectorcraft-cli --help "; +/// The command line's options other than the automation roots and the control port. +struct CommandLine { + files: Vec, + in_window_menus: bool, + new_instance: bool, + /// `--safe-mode`: no plug-in loads or runs in this launch. + safe_mode: bool, +} + +/// Reads the arguments after the program name. The automation roots and the control port go into +/// `roots_args` and `control`. `Err` is the exit code when the process ends here: `--version` and +/// `--help` print and succeed; a bad automation option or an unknown option prints an error and fails. +fn read_command_line( + mut args: impl Iterator, + control: &mut automation_args::ControlArg, + roots_args: &mut automation_args::AutomationArgs, +) -> Result { + let mut files = Vec::new(); + let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"); + let mut new_instance = false; + let mut safe_mode = false; + let mut unknown = None; + while let Some(a) = args.next() { + // After `--`, every argument is a file, even one whose name starts with '-'. + if a == "--" { + files.extend(args.by_ref()); + break; + } + match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) { + Ok(true) => continue, + Ok(false) => {} + Err(e) => { + eprintln!("vectorcraft: {e}"); + return Err(std::process::ExitCode::FAILURE); + } + } + match a.as_str() { + "--in-window-menus" => in_window_menus = true, + "--new-instance" => new_instance = true, + "--safe-mode" => safe_mode = true, + "--version" => { + println!("vectorcraft {}", env!("CARGO_PKG_VERSION")); + return Err(std::process::ExitCode::SUCCESS); + } + // Before anything starts, like `--version`: from a terminal or an agent's shell the app + // used to open (or hang without a display) instead (#2110). + "--help" | "-h" => { + // Not `print!`, which panics when standard output is closed (`| head -0`): + // there is nothing left to tell then. + let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes()); + return Err(std::process::ExitCode::SUCCESS); + } + // The process serial number older macOS versions pass to an app the Finder starts. + #[cfg(target_os = "macos")] + _ if a.starts_with("-psn_") => {} + // An option this version doesn't know (a typo such as `--safe-mod`) is an error, not a + // file to open. Reported after the loop, so a later `--help` or `--version` still wins. + _ if a.starts_with('-') && a != "-" => { + unknown.get_or_insert(a); + } + _ => files.push(a), + } + } + if let Some(a) = unknown { + eprintln!("vectorcraft: unknown option {a}; see vectorcraft --help, or put -- before a file whose name starts with '-'"); + return Err(std::process::ExitCode::FAILURE); + } + Ok(CommandLine { files, in_window_menus, new_instance, safe_mode }) +} + fn main() -> std::process::ExitCode { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] @@ -154,37 +238,11 @@ fn main() -> std::process::ExitCode { let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from); let mut roots_args = automation_args::AutomationArgs::new(root_env("VECTORCRAFT_AUTOMATION_READ_ROOT"), root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT")); - let mut files = Vec::new(); - let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"); - let mut new_instance = false; - let mut args = std::env::args().skip(1); - while let Some(a) = args.next() { - match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) { - Ok(true) => continue, - Ok(false) => {} - Err(e) => { - eprintln!("vectorcraft: {e}"); - return std::process::ExitCode::FAILURE; - } - } - match a.as_str() { - "--in-window-menus" => in_window_menus = true, - "--new-instance" => new_instance = true, - "--version" => { - println!("vectorcraft {}", env!("CARGO_PKG_VERSION")); - return std::process::ExitCode::SUCCESS; - } - // Before anything starts, like `--version`: from a terminal or an agent's shell the app - // used to open (or hang without a display) instead (#2110). - "--help" | "-h" => { - // Not `print!`, which panics when standard output is closed (`| head -0`): - // there is nothing left to tell then. - let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes()); - return std::process::ExitCode::SUCCESS; - } - _ => files.push(a), - } - } + let CommandLine { files, in_window_menus, new_instance, safe_mode: safe_mode_flag } = + match read_command_line(std::env::args().skip(1), &mut control, &mut roots_args) { + Ok(command_line) => command_line, + Err(code) => return code, + }; let automation_args::AutomationArgs { read_roots, write_roots, .. } = roots_args; // After the arguments: `--control` and `--version` win over a bad environment value. let control_port = match control.port { @@ -202,10 +260,10 @@ fn main() -> std::process::ExitCode { // Files opened from the file manager while VectorCraft runs open in its window (#4892). Before // the log file is attached: a launch that hands its files over leaves the running app's log be. #[cfg(not(target_os = "macos"))] - let instance = match single_instance::start(control_port.is_some(), new_instance, &files) { + match single_instance::start(control_port.is_some(), new_instance, safe_mode_flag, &files) { single_instance::Start::Forwarded => return std::process::ExitCode::SUCCESS, - single_instance::Start::Running(primary) => primary, - }; + single_instance::Start::Running(primary) => single_instance::hold(primary), + } #[cfg(target_os = "macos")] let _ = new_instance; vectorcraft_ui_egui::i18n::detect_system_lang_in_background(); @@ -396,10 +454,15 @@ fn main() -> std::process::ExitCode { let mut session = Session::new(); // The profiles loaded before come back before the preferences name any of them. prefs_dir::attach_profile_store(&mut session); + // Before any plug-in loads: this launch's marker in the Launch folder next to the + // preferences (none for runs without preferences), and Safe Mode. + let launch_dir = prefs_path().filter(|_| prefs_enabled()).and_then(|p| Some(p.parent()?.join("Launch"))); + let stopped = safe_mode::begin_launch(launch_dir.as_deref(), safe_mode_flag); prefs_dir::attach_plugin_store(&mut session); let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone())); app.set_startup_update_check(true); load_prefs(&mut app, saved); + safe_mode::after_start(&mut app, stopped); // The saved PDF presets live in their own folder (also read by vectorcraft-cli), // which takes over from the copy the preferences file used to hold. prefs_dir::attach_pdf_preset_store(&mut app.session); @@ -466,7 +529,9 @@ fn main() -> std::process::ExitCode { let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone()); rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}"))); } + app.titlebar_from_env = titlebar_from_env(); app.custom_titlebar = custom_titlebar; + app.desktop_frame = !custom_titlebar; if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); app = app.with_control(rx).with_automation_roots(roots.clone()); @@ -491,7 +556,7 @@ fn main() -> std::process::ExitCode { single_instance::set_ui(&cc.egui_ctx); } app.services.native_input_hook = native_input_hook(&app); - file_access::unconfined(|| open_files(&mut app, files)); + let held_files = file_access::unconfined(|| open_or_hold(&mut app, files)); // The first frame is due from now (`gpu::watch_first_frame`). #[cfg(feature = "wgpu")] created.ready(); @@ -507,6 +572,7 @@ fn main() -> std::process::ExitCode { winit_window, pending_maximize, maximize_tries: 8, + held_files, monitors_listed: None, })) }), @@ -515,7 +581,9 @@ fn main() -> std::process::ExitCode { // The window is gone: later launches start their own app, including the one a graphics // failure starts again below. #[cfg(not(target_os = "macos"))] - drop(instance); + single_instance::resign(); + // The process ends normally from here: this launch didn't stop while a plug-in was loading. + safe_mode::end_launch(); #[cfg(feature = "wgpu")] let outcome = gpu::finish(outcome, &startup); #[cfg(not(feature = "wgpu"))] diff --git a/apps/vectorcraft/src/prefs.rs b/apps/vectorcraft/src/prefs.rs index f03572ebb..c57ab6b58 100644 --- a/apps/vectorcraft/src/prefs.rs +++ b/apps/vectorcraft/src/prefs.rs @@ -168,6 +168,11 @@ fn same_lasting(a: &serde_json::Value, b: &serde_json::Value) -> bool { } } +/// Fields that a read fills in from other fields when the file lacks them +/// (`vectorcraft_ui_egui::layout_migration`): `help_bar` follows `status_bar`. A save writes them +/// whenever the file lacks them, so the next read does not fill them in again from a changed field. +const DERIVED: &[&str] = &["help_bar"]; + /// `on_disk` with what changed from `base` to `mine` put over it: each top-level field of the UI /// state, and each engine preference (`engine_prefs`) on its own, so two windows that change /// different settings keep both. A field both changed takes this window's value (it saves last). @@ -219,6 +224,13 @@ fn merge_changes(base: &serde_json::Value, mine: &serde_json::Value, on_disk: se } } } + for key in DERIVED { + if !out.contains_key(*key) + && let Some(v) = mine.get(*key) + { + out.insert((*key).to_string(), v.clone()); + } + } if use_mine { match mine.get("action_set") { Some(v) => { @@ -383,6 +395,20 @@ mod tests { assert!(!same_lasting(&base, &mine)); } + /// A file from before the Help Bar setting: the read shows the Help Bar with the status bar. + /// After the status bar is hidden and the Help Bar left showing, the save writes `help_bar`, + /// so the next read does not hide it with the status bar. A value on disk is kept. + #[test] + fn a_help_bar_setting_the_file_lacks_is_saved() { + let base = json!({"status_bar": true, "help_bar": true}); + let mine = json!({"status_bar": false, "help_bar": true}); + let on_disk = json!({"status_bar": true}); + assert_eq!(merge_changes(&base, &mine, on_disk), json!({"status_bar": false, "help_bar": true})); + // Another window saved its own Help Bar setting: it stays. + let on_disk = json!({"status_bar": true, "help_bar": false}); + assert_eq!(merge_changes(&base, &mine, on_disk), json!({"status_bar": false, "help_bar": false})); + } + /// One window deleted the first Actions set and saved; this one made the second set current. /// The saved index follows that set to its place in the merged list. #[test] diff --git a/apps/vectorcraft/src/single_instance.rs b/apps/vectorcraft/src/single_instance.rs index 5ced4aef3..37a836f6f 100644 --- a/apps/vectorcraft/src/single_instance.rs +++ b/apps/vectorcraft/src/single_instance.rs @@ -12,8 +12,12 @@ //! as its own app, which replaces a stale file. Nothing received is ever run. //! //! Runs that start many apps side by side (`--control`, `VECTORCRAFT_NO_PREFS`) neither forward -//! nor listen, and `--new-instance` opens a launch's files in an app of its own. macOS hands files -//! opened from Finder to the running app itself (`open_documents`), so this is off there. +//! nor listen, and neither does a `--safe-mode` launch, which opens its files in an app of its own +//! and leaves later launches to start theirs with plug-ins. `--new-instance` opens a launch's files +//! in an app of its own. An app that stays in Safe Mode after the choice about a stopped plug-in +//! stops listening ([`resign`]), and files later launches handed it before then open in a new app +//! with plug-ins ([`take_for_frame`]). macOS hands files opened from Finder to the running app itself +//! (`open_documents`), so this is off there. use std::fs::{self, OpenOptions}; use std::hash::{BuildHasher as _, RandomState}; @@ -43,6 +47,8 @@ const CLIENT_TIMEOUT: Duration = Duration::from_secs(5); /// Paths handed over by later launches that the UI hasn't opened yet. static PENDING: Mutex> = Mutex::new(Vec::new()); +/// This app while it is the one later launches hand their files to ([`hold`]). +static HELD: Mutex> = Mutex::new(None); /// The UI to wake when files arrive (unset until the window exists; the first frame opens them). static UI: OnceLock = OnceLock::new(); @@ -56,23 +62,46 @@ pub enum Start { /// Hand `files` to the app already running, or become the app later launches hand theirs to. /// `automation`: started with `--control`. -pub fn start(automation: bool, new_instance: bool, files: &[String]) -> Start { +pub fn start(automation: bool, new_instance: bool, safe_mode: bool, files: &[String]) -> Start { if automation || !prefs_enabled() { return Start::Running(None); } let Some(path) = prefs_path().and_then(|p| Some(p.parent()?.join(FILE_NAME))) else { return Start::Running(None) }; + start_at(&path, new_instance, safe_mode, files) +} + +/// [`start`] with the endpoint file `path`. A launch with `--new-instance` or `--safe-mode` keeps +/// its files and runs as its own app: a running app would open them with its plug-ins loaded. A +/// `--safe-mode` launch never becomes the app later launches hand their files to, which would open +/// them without plug-ins. +fn start_at(path: &Path, new_instance: bool, safe_mode: bool, files: &[String]) -> Start { + if safe_mode { + return Start::Running(None); + } // With nothing to hand over, the same exchange only asks whether an app is running. let send: &[String] = if new_instance { &[] } else { files }; - match forward(&path, send) { + match forward(path, send) { Ok(()) if send.is_empty() => Start::Running(None), Ok(()) => Start::Forwarded, Err(e) => { log::debug!("no running VectorCraft to hand files to ({e})"); - Start::Running(listen(&path, queue)) + Start::Running(listen(path, queue)) } } } +/// Keep `primary` ([`start`]'s) until [`resign`]. +pub fn hold(primary: Option) { + *HELD.lock().unwrap_or_else(PoisonError::into_inner) = primary; +} + +/// Later launches start their own app from now on: the endpoint file goes. The app calls it when +/// its window is gone, and when it stays in Safe Mode after the choice about a stopped plug-in. +pub fn resign() { + let primary = HELD.lock().unwrap_or_else(PoisonError::into_inner).take(); + drop(primary); +} + /// Wake `ctx` when files arrive from later launches. pub fn set_ui(ctx: &egui::Context) { // Set once; a second call would hand over the same context. @@ -84,6 +113,43 @@ pub fn take() -> Vec { std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner)) } +/// The paths later launches handed over since the last call, to open in this app. An app that +/// stays in Safe Mode (`safe_mode`) resigns and hands them to a new app with plug-ins (`launch`, +/// [`launch_with_plug_ins`] in the app) instead: they arrived while it was the primary, before the +/// choice about a stopped plug-in ended in Safe Mode, or just before it resigned. When `launch` +/// fails (logged), they open here. +pub fn take_for_frame(safe_mode: bool, launch: impl FnOnce(&[String]) -> io::Result<()>) -> Vec { + let files = take(); + if !safe_mode { + return files; + } + // First, so the new app becomes the primary instead of handing the files back. + resign(); + if files.is_empty() { + return files; + } + match launch(&files) { + Ok(()) => Vec::new(), + Err(e) => { + log::warn!("couldn't start VectorCraft with plug-ins for the files another launch opened: {e}"); + files + } + } +} + +/// Start this program again, with plug-ins, on `files`. +pub fn launch_with_plug_ins(files: &[String]) -> io::Result<()> { + app_with_plug_ins(&std::env::current_exe()?, files).spawn().map(drop) +} + +/// The command [`launch_with_plug_ins`] runs: `exe -- files…`, so a file whose name starts with '-' +/// stays a file. +fn app_with_plug_ins(exe: &Path, files: &[String]) -> std::process::Command { + let mut command = std::process::Command::new(exe); + command.arg("--").args(files); + command +} + fn queue(files: Vec) { PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(files); if let Some(ctx) = UI.get() { @@ -263,6 +329,13 @@ mod tests { use super::*; + /// Held by the tests that use the process-wide [`HELD`] and [`PENDING`], one at a time. + static GLOBALS: Mutex<()> = Mutex::new(()); + + fn globals() -> std::sync::MutexGuard<'static, ()> { + GLOBALS.lock().unwrap_or_else(PoisonError::into_inner) + } + struct TestDir(tempfile::TempDir); impl std::ops::Deref for TestDir { @@ -313,6 +386,72 @@ mod tests { assert!(!path.exists(), "the endpoint file goes with the app"); } + #[test] + fn a_new_instance_or_safe_mode_launch_keeps_its_files() { + let (path, _dir, file) = folder("own"); + let (primary, rx) = primary(&path); + for (new_instance, safe_mode) in [(true, false), (false, true)] { + let start = start_at(&path, new_instance, safe_mode, std::slice::from_ref(&file)); + assert!(matches!(start, Start::Running(None)), "runs as its own app (new instance {new_instance}, Safe Mode {safe_mode})"); + } + assert!(matches!(start_at(&path, false, false, std::slice::from_ref(&file)), Start::Forwarded)); + assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), std::slice::from_ref(&file)); + assert!(rx.try_recv().is_err(), "only the plain launch handed its file over"); + drop(primary); + } + + #[test] + fn a_safe_mode_launch_leaves_later_launches_their_own_app() { + let _globals = globals(); + let (path, _dir, file) = folder("safe-mode-alone"); + // With no app running, a Safe Mode launch runs without listening. + assert!(matches!(start_at(&path, false, true, std::slice::from_ref(&file)), Start::Running(None))); + assert!(!path.exists(), "a Safe Mode launch records no endpoint"); + // A plain launch after it starts its own app and takes later launches' files. + let Start::Running(Some(primary)) = start_at(&path, false, false, std::slice::from_ref(&file)) else { + panic!("the plain launch runs and listens") + }; + assert!(path.exists()); + // An app that stays in Safe Mode after the choice gives that up. + hold(Some(primary)); + resign(); + assert!(!path.exists(), "the endpoint file goes with resign"); + } + + #[test] + fn files_handed_to_an_app_staying_in_safe_mode_go_to_a_new_app_with_plug_ins() { + let _globals = globals(); + // Nothing left over from a test that failed. + resign(); + drop(take()); + let (path, _dir, file) = folder("safe-mode-later"); + let Start::Running(Some(primary)) = start_at(&path, false, false, &[]) else { panic!("the first launch listens") }; + hold(Some(primary)); + // A later launch handed its file over while the choice about a stopped plug-in was up. + queue(vec![file.clone()]); + let mut launched = Vec::new(); + let here = take_for_frame(true, |files| { + launched = files.to_vec(); + Ok(()) + }); + assert!(here.is_empty(), "nothing opens in the Safe Mode app"); + assert_eq!(launched, std::slice::from_ref(&file), "a new app with plug-ins opens it"); + assert!(!path.exists(), "the app resigned before starting the new one"); + // When the new app can't start, the file opens here rather than being lost. + queue(vec![file.clone()]); + assert_eq!(take_for_frame(true, |_| Err(io::Error::other("no program"))), std::slice::from_ref(&file)); + // An app with plug-ins opens what it is handed. + queue(vec![file.clone()]); + assert_eq!(take_for_frame(false, |_| panic!("no new app")), [file]); + } + + #[test] + fn the_new_app_takes_every_name_as_a_file() { + let command = app_with_plug_ins(Path::new("vectorcraft"), &["-a.svg".to_owned(), "b.svg".to_owned()]); + let args: Vec<_> = command.get_args().collect(); + assert_eq!(args, ["--", "-a.svg", "b.svg"]); + } + #[test] fn a_wrong_token_garbage_and_oversized_messages_are_refused() { let (path, _dir, file) = folder("refused"); diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs index 116651fc8..6e61cbe31 100644 --- a/apps/vectorcraft/src/tests.rs +++ b/apps/vectorcraft/src/tests.rs @@ -36,6 +36,33 @@ fn shortcut_overrides_from_an_earlier_version_load() { assert!(km.problems().iter().any(|p| format!("{p:?}") == r#"UnknownCommand { key: "test.unknown" }"#), "reported"); } +/// The command line's files wait for the choice about a stopped plug-in, so they open with the +/// plug-ins it loads; with no choice up they open at once. +#[test] +fn command_line_files_wait_for_the_choice_about_a_stopped_plug_in() { + let temp = tempfile::Builder::new().prefix("vectorcraft-held-files-").tempdir().unwrap(); + let file = temp.path().join("a.svg"); + std::fs::write(&file, r#""#).unwrap(); + let files = vec![file.to_string_lossy().into_owned()]; + // The files the app reads to open them. + let read = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let reads = read.clone(); + let services = vectorcraft_ui_egui::Services { + read: Some(Box::new(move |p: &str| { + reads.borrow_mut().push(p.to_owned()); + std::fs::read(p).map_err(|e| e.to_string()) + })), + ..Default::default() + }; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), services); + app.ui.stopped_plugin = Some(serde_json::json!({"id": "test.held", "name": "Held"})); + assert_eq!(open_or_hold(&mut app, files.clone()), files, "held while the choice is up"); + assert!(read.borrow().is_empty(), "nothing opened before the choice"); + app.ui.stopped_plugin = None; + assert!(open_or_hold(&mut app, files.clone()).is_empty()); + assert_eq!(*read.borrow(), files, "opened with no choice up"); +} + #[test] fn opening_files_unminimizes_and_focuses_the_window() { let ctx = egui::Context::default(); @@ -164,6 +191,134 @@ fn the_system_title_bar_preference_keeps_the_window_decorations() { assert_eq!(super::native_options(true).viewport.decorations, Some(false)); } +/// Preferences › User Interface › VectorCraft Title Bar on Every Desktop gives the app frame where +/// the probe chose the desktop's (KDE, X11, a window opened through XWayland); without it the probe +/// decides, and `VECTORCRAFT_TITLEBAR` set to `app`, `system` or `desktop` takes precedence over +/// both. +#[test] +fn the_app_title_bar_preference_gives_the_app_frame_on_desktop_frames() { + assert!(!super::opted_into_app_title_bar(None), "no preferences: the default"); + let off = ui_state_with_prefs(serde_json::json!({"appTitleBar": false})); + assert!(!super::opted_into_app_title_bar(Some(&off))); + let on = ui_state_with_prefs(serde_json::json!({"appTitleBar": true})); + assert!(super::opted_into_app_title_bar(Some(&on))); + + // (probed_app, overridden, forced_to_x11): without the preference, then with it. + let cases = [ + ((false, None, false), false, true), // KDE, Sway, X11: the desktop frame + ((true, None, false), true, true), // GNOME on Wayland: the app frame either way + ((true, None, true), false, true), // GNOME, the window opened through XWayland + ((false, Some(true), false), true, true), // VECTORCRAFT_TITLEBAR=app + ((false, Some(true), true), true, true), // VECTORCRAFT_TITLEBAR=app, the window opened through XWayland + ((true, Some(false), false), false, false), // VECTORCRAFT_TITLEBAR=desktop takes precedence over the preference + ((false, Some(false), true), false, false), + ]; + for ((probed_app, overridden, forced_to_x11), without, with) in cases { + assert_eq!(super::app_draws_frame(probed_app, overridden, forced_to_x11, false), without, "{probed_app} {overridden:?} {forced_to_x11}"); + assert_eq!(super::app_draws_frame(probed_app, overridden, forced_to_x11, true), with, "{probed_app} {overridden:?} {forced_to_x11}"); + } +} + +/// The frame a start uses: the preference turns the desktop frame the probe chose into the app frame +/// with the default window buttons, a probed app frame keeps its own buttons, and the desktop frame +/// stays where the app frame is not drawn. System Title Bar takes precedence over the preference. +#[test] +fn the_start_frame_applies_the_app_title_bar_preference() { + use craft_window::{Button, Frame, Layout}; + let gnome = Layout { left: vec![Button::Close], right: vec![] }; + let cases = [ + // (probed, overridden, forced_to_x11, opted_into_app), expected + ((Frame::Desktop, None, false, false), Frame::Desktop), + ((Frame::Desktop, None, false, true), Frame::App(Layout::default())), + ((Frame::Desktop, None, true, true), Frame::App(Layout::default())), + ((Frame::App(gnome.clone()), None, false, false), Frame::App(gnome.clone())), + ((Frame::App(gnome.clone()), None, true, false), Frame::Desktop), + ((Frame::App(gnome.clone()), None, true, true), Frame::App(gnome.clone())), + ((Frame::Desktop, Some(false), false, true), Frame::Desktop), + ((Frame::App(gnome.clone()), Some(true), true, false), Frame::App(gnome.clone())), + ]; + for ((probed, overridden, forced_to_x11, opted_into_app), expected) in cases { + let label = format!("{probed:?} {overridden:?} {forced_to_x11} {opted_into_app}"); + assert_eq!(super::start_frame(probed, overridden, forced_to_x11, opted_into_app), expected, "{label}"); + } + // With both preferences on, System Title Bar takes precedence: the desktop draws the frame. + let both = super::start_frame(Frame::Desktop, None, false, true); + assert!(!super::custom_titlebar_from(&both, true), "System Title Bar takes precedence over the app title bar preference"); +} + +/// `VECTORCRAFT_TITLEBAR` chooses the frame for the run it is set in, with System Title Bar off and +/// on. The saved preference still decides while the variable is unset. +#[test] +fn the_titlebar_variable_takes_precedence_over_system_title_bar() { + use craft_window::Frame; + assert!(super::system_title_bar_applies(true, false)); + assert!(!super::system_title_bar_applies(true, true)); + assert!(!super::system_title_bar_applies(false, false)); + assert!(!super::system_title_bar_applies(false, true)); + // VECTORCRAFT_TITLEBAR=app with System Title Bar on: VectorCraft draws its own title bar. + let frame = super::start_frame(Frame::Desktop, Some(true), false, false); + assert_eq!(super::custom_titlebar_from(&frame, super::system_title_bar_applies(true, true)), !cfg!(target_os = "macos")); + // VECTORCRAFT_TITLEBAR=desktop with System Title Bar off keeps the desktop's frame. + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + let frame = super::start_frame(Frame::App(craft_window::Layout::default()), Some(false), false, false); + assert!(!super::custom_titlebar_from(&frame, super::system_title_bar_applies(false, true))); + } +} + +/// `VECTORCRAFT_TITLEBAR` read from the environment at startup, with System Title Bar off and on. +/// The variable is process-wide and read once, so each value runs in a separate test process. +#[test] +fn the_titlebar_variable_decides_the_frame_before_the_window_opens() { + const CHILD: &str = "VECTORCRAFT_TEST_TITLEBAR_CHILD"; + if std::env::var_os(CHILD).is_none() { + for value in ["app", "system", "desktop", "unknown"] { + // Linux and the BSDs: hold `spawning` while the child starts, so it does not keep a + // copy of a GPU start test's locked start file (see `CHILD_SPAWN`). + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + let spawning = super::spawning(); + let child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "tests::the_titlebar_variable_decides_the_frame_before_the_window_opens", "--nocapture"]) + .env(CHILD, "1") + .env("VECTORCRAFT_TITLEBAR", value) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn(); + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + drop(spawning); + let output = child.unwrap().wait_with_output().unwrap(); + assert!( + output.status.success(), + "VECTORCRAFT_TITLEBAR={value}\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + return; + } + let value = std::env::var("VECTORCRAFT_TITLEBAR").unwrap(); + let not_read = cfg!(any(target_os = "macos", target_os = "windows")); + assert_eq!(super::titlebar_from_env(), !not_read && value != "unknown", "VECTORCRAFT_TITLEBAR={value}"); + let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false})); + let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true})); + for (saved, opted_into_system) in [(None, false), (Some(&off), false), (Some(&on), true)] { + let custom = super::custom_titlebar(saved); + let expected = match value.as_str() { + _ if not_read => !cfg!(target_os = "macos") && !opted_into_system, + "app" => true, + "system" | "desktop" => false, + // Not a value the variable takes: System Title Bar decides when it is on. Otherwise the + // `craft-window` probe decides, the same with System Title Bar off as with no saved + // preferences. + _ if opted_into_system => false, + _ => super::custom_titlebar(None), + }; + assert_eq!(custom, expected, "{value}, preference {opted_into_system}"); + assert_eq!(super::native_options(custom).viewport.decorations, Some(!custom), "{value}, preference {opted_into_system}"); + } +} + /// The file extensions the macOS bundle declares: its document types and its own exported type. fn plist_extensions(plist: &str) -> Vec<&str> { ["CFBundleTypeExtensions", "public.filename-extension"] diff --git a/apps/vectorcraft/src/titlebar.rs b/apps/vectorcraft/src/titlebar.rs index 63c9f7c13..fc6e39592 100644 --- a/apps/vectorcraft/src/titlebar.rs +++ b/apps/vectorcraft/src/titlebar.rs @@ -41,11 +41,66 @@ pub(crate) const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); /// Whether the user asked for the system title bar in Preferences › User Interface › System Title /// Bar. Read from the saved preferences before the window opens; a missing or unreadable value is -/// `false` (the default). This preference forces the desktop/native frame back on. +/// `false` (the default). This preference forces the desktop/native frame back on unless +/// `VECTORCRAFT_TITLEBAR` is set to `app`, `system` or `desktop` ([`system_title_bar_applies`]). pub(crate) fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false) } +/// Whether the user asked for VectorCraft's own title bar in Preferences › User Interface › +/// VectorCraft Title Bar on Every Desktop (Linux and the BSDs). Read like +/// [`opted_into_system_title_bar`]; a missing or unreadable value is `false` (the default). +pub(crate) fn opted_into_app_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { + saved.map(|ui| ui.engine_prefs.get("appTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false) +} + +/// Whether the app draws the frame on this start, before the System Title Bar preference is +/// applied ([`custom_titlebar_from`]). `probed_app` is the `craft-window` probe's answer and +/// `overridden` its `VECTORCRAFT_TITLEBAR` override. In order: the override decides when the +/// variable is set to `app`, `system` or `desktop`;VectorCraft Title Bar on Every Desktop (`opted_into_app`) gives the app frame; a window +/// that opens through XWayland (`forced_to_x11`) gets the desktop frame; otherwise the probe +/// decides. +pub(crate) fn app_draws_frame(probed_app: bool, overridden: Option, forced_to_x11: bool, opted_into_app: bool) -> bool { + match overridden { + Some(app) => app, + None => opted_into_app || (probed_app && !forced_to_x11), + } +} + +/// The frame this start uses, before the System Title Bar preference is applied +/// ([`custom_titlebar_from`]): [`app_draws_frame`] applied to the frame the probe chose. A probed +/// app frame keeps its window-button layout; an app frame the probe did not choose gets the default +/// layout, since `custom_titlebar_from` reads only which frame it is. +pub(crate) fn start_frame(probed: craft_window::Frame, overridden: Option, forced_to_x11: bool, opted_into_app: bool) -> craft_window::Frame { + let probed_app = matches!(probed, craft_window::Frame::App(_)); + match (app_draws_frame(probed_app, overridden, forced_to_x11, opted_into_app), probed) { + (true, frame @ craft_window::Frame::App(_)) => frame, + (true, craft_window::Frame::Desktop) => craft_window::Frame::App(craft_window::Layout::default()), + (false, _) => craft_window::Frame::Desktop, + } +} + +/// The `craft-window` probe's answer for this process (its override variable is +/// `VECTORCRAFT_TITLEBAR`). The probe runs once; on a Wayland session it opens a connection. +fn probed_choice() -> &'static craft_window::Choice { + static CHOICE: std::sync::OnceLock = std::sync::OnceLock::new(); + CHOICE.get_or_init(|| craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT"))) +} + +/// Whether `VECTORCRAFT_TITLEBAR` chose this window's frame (Linux and the BSDs), so Preferences › +/// User Interface › System Title Bar and VectorCraft Title Bar on Every Desktop do not apply while +/// it is set to `app`, `system` or `desktop`. Always `false` on macOS and Windows. +pub(crate) fn titlebar_from_env() -> bool { + cfg!(not(any(target_os = "macos", target_os = "windows"))) && probed_choice().overridden().is_some() +} + +/// Whether System Title Bar decides this start: `opted_into_system` unless `VECTORCRAFT_TITLEBAR` +/// is set to `app`, `system` or `desktop` (`from_env`), which chooses the frame for the run it is +/// set in. +pub(crate) fn system_title_bar_applies(opted_into_system: bool, from_env: bool) -> bool { + opted_into_system && !from_env +} + /// Whether this start draws its own title bar, given the frame the shared `craft-window` crate /// decided and the user's System Title Bar preference. /// @@ -53,9 +108,10 @@ pub(crate) fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::Ui /// ([`craft_window::Frame::Desktop`]) on macOS, Windows, X11 and any Wayland compositor with /// server-side decorations (KDE, Sway, Hyprland, COSMIC); the app draws it /// ([`craft_window::Frame::App`]) only where the compositor leaves it to the app (GNOME's Mutter), -/// or when `VECTORCRAFT_TITLEBAR` overrides the probe. VectorCraft draws its own title bar exactly +/// or when `VECTORCRAFT_TITLEBAR` set to `app`, `system` or `desktop` overrides the probe. VectorCraft draws its own title bar exactly /// when `craft-window` says the app owns the frame **and** the user has not opted into the system -/// title bar; the preference always forces the native frame back on. +/// title bar; the preference forces the native frame back on unless `VECTORCRAFT_TITLEBAR` is set +/// to one of those values ([`system_title_bar_applies`]). /// /// macOS and Windows keep today's behavior and are decided by [`CUSTOM_TITLEBAR`] rather than the /// probe: `craft-window` returns `Frame::Desktop` for both (the OS always draws a frame), but @@ -77,29 +133,34 @@ pub(crate) fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_syst /// Whether this start draws its own title bar. Probes the shared `craft-window` crate once (its /// override variable is `VECTORCRAFT_TITLEBAR`) and combines its decision with the saved System -/// Title Bar preference. Call once at startup, before [`native_options`]. +/// Title Bar preference, which does not apply while the variable is set to `app`, `system` or +/// `desktop`. Call once at startup, +/// before [`native_options`]. /// /// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through /// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs /// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native -/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins. +/// frame, the same as a real X11 session. A recognized `VECTORCRAFT_TITLEBAR` override still wins, +/// and so does VectorCraft Title Bar on Every Desktop ([`app_draws_frame`]). pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { - let choice = craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT")); - let opted_into_system = opted_into_system_title_bar(saved); + let choice = probed_choice(); + let opted_into_system = system_title_bar_applies(opted_into_system_title_bar(saved), titlebar_from_env()); + // macOS and Windows never read VectorCraft Title Bar on Every Desktop (`custom_titlebar_from` + // ignores the frame there). + let opted_into_app = cfg!(not(any(target_os = "macos", target_os = "windows"))) && opted_into_app_title_bar(saved); #[cfg(target_os = "linux")] - { + // When the window will actually open on X11 (XWayland) — the user asked for it *and* the X11 + // libraries are present, the same two conditions the launch path checks below — that run is + // effectively X11, so keep the native frame. If the libraries are missing, startup stays on + // Wayland, so the probe's own decision (app-drawn on GNOME) must stand. + let forced_to_x11 = { let pref = saved.and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str); - // `overridden() == None` means the probe decided (no VECTORCRAFT_TITLEBAR). When the window - // will actually open on X11 (XWayland) — the user asked for it *and* the X11 libraries are - // present, the same two conditions the launch path checks below — that run is effectively - // X11, so keep the native frame. If the libraries are missing, startup stays on Wayland, so - // the probe's own decision (app-drawn on GNOME) must stand. - let forced_to_x11 = xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found(); - if choice.overridden().is_none() && forced_to_x11 { - return custom_titlebar_from(&craft_window::Frame::Desktop, opted_into_system); - } - } - custom_titlebar_from(choice.frame(), opted_into_system) + xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found() + }; + #[cfg(not(target_os = "linux"))] + let forced_to_x11 = false; + let frame = start_frame(choice.frame().clone(), choice.overridden(), forced_to_x11, opted_into_app); + custom_titlebar_from(&frame, opted_into_system) } pub(crate) fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { diff --git a/apps/vectorcraft/src/windows_alt_codes.rs b/apps/vectorcraft/src/windows_alt_codes.rs index a4ef28dbb..b370bf214 100644 --- a/apps/vectorcraft/src/windows_alt_codes.rs +++ b/apps/vectorcraft/src/windows_alt_codes.rs @@ -13,6 +13,13 @@ //! same undo step as the typing round it. The keypad digits themselves are taken out of the frame, //! so they don't run a shortcut bound to Alt and a digit. //! +//! With NumLock off the keypad keys arrive as Insert, End, Down, Page Down, Left, Clear, Right, +//! Home, Up and Page Up, and Windows still composes the code from them. They are told from the +//! separate navigation keys by the extended-key flag, which only the separate keys set +//! (), +//! so with Alt held they count as digits and are taken out of the frame too, rather than moving +//! the caret before the character arrives. +//! //! egui reports a keypad digit as the same key as the digit above the letters, so the other //! platforms, whose systems have no such convention, can't tell them apart either. @@ -28,10 +35,20 @@ const WM_CHAR: u32 = 0x0102; const WM_SYSKEYDOWN: u32 = 0x0104; const WM_SYSKEYUP: u32 = 0x0105; const WM_SYSCHAR: u32 = 0x0106; -/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on. With it off the keys -/// are Insert, End, … and type no code. +/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on. const VK_NUMPAD0: usize = 0x60; const VK_NUMPAD9: usize = 0x69; +/// The keypad's virtual keys with NumLock off, by digit (`winuser.h`): `VK_INSERT`, `VK_END`, +/// `VK_DOWN`, `VK_NEXT`, `VK_LEFT`, `VK_CLEAR`, `VK_RIGHT`, `VK_HOME`, `VK_UP` and `VK_PRIOR`. +const NUMLOCK_OFF_KEYS: [usize; 10] = [0x2D, 0x23, 0x28, 0x22, 0x25, 0x0C, 0x27, 0x24, 0x26, 0x21]; +/// The keys egui reports for the keypad with NumLock off, by digit (Clear has no egui key, so it +/// comes as the top-row digit key, `Num5`). +const NUMLOCK_OFF_EGUI_KEYS: [Key; 10] = + [Key::Insert, Key::End, Key::ArrowDown, Key::PageDown, Key::ArrowLeft, Key::Num5, Key::ArrowRight, Key::Home, Key::ArrowUp, Key::PageUp]; +const DIGIT_KEYS: [Key; 10] = [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5, Key::Num6, Key::Num7, Key::Num8, Key::Num9]; +/// Bit 24 of a key message's `lParam`: an extended key, such as the navigation keys beside the +/// keypad (the keypad's own keys clear it). +const EXTENDED_KEY: isize = 1 << 24; /// `VK_MENU`, `VK_LMENU` and `VK_RMENU`: Alt. const ALT_KEYS: [usize; 3] = [0x12, 0xA4, 0xA5]; @@ -51,23 +68,42 @@ struct AltCodes { high: Option, /// Characters the codes typed, for the next frame. typed: String, - /// Keypad digit presses and releases with Alt held since the last frame, which egui reports - /// as Alt and a digit: taken out of the frame. - keypad_events: usize, + /// The digits of the keypad presses and releases with Alt held since the last frame, which + /// egui reports as Alt and a digit (or, with NumLock off, Alt and a navigation key): taken + /// out of the frame. + keypad_events: Vec, +} + +/// The digit a keypad key stands for in an Alt code: `wparam` is its virtual-key code and +/// `lparam` the message's flags. +fn keypad_digit(wparam: usize, lparam: isize) -> Option { + if (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam) { + return Some(wparam - VK_NUMPAD0); + } + if lparam & EXTENDED_KEY != 0 { + return None; + } + NUMLOCK_OFF_KEYS.iter().position(|&k| k == wparam) } impl AltCodes { - /// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit). - fn message(&mut self, message: u32, wparam: usize) { - let keypad = (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam); - match message { + /// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit) + /// and `lparam` (its flags). + fn message(&mut self, message: u32, wparam: usize, lparam: isize) { + let keypad = match message { + WM_SYSKEYDOWN | WM_SYSKEYUP => keypad_digit(wparam, lparam), + _ => None, + }; + if let Some(d) = keypad { // Windows sends a key pressed with Alt held as a system key. - WM_SYSKEYDOWN if keypad => { + if message == WM_SYSKEYDOWN { self.digits += 1; - self.keypad_events += 1; self.armed = false; } - WM_SYSKEYUP if keypad => self.keypad_events += 1, + self.keypad_events.push(d); + return; + } + match message { WM_KEYDOWN | WM_SYSKEYDOWN if ALT_KEYS.contains(&wparam) => {} // Any other key ends the sequence and keeps its own meaning. WM_KEYDOWN | WM_SYSKEYDOWN => self.reset(), @@ -99,14 +135,23 @@ impl AltCodes { /// out the keypad digits typed with Alt held. fn drain(&mut self, events: &mut Vec) { let mut keypad = std::mem::take(&mut self.keypad_events); - if keypad > 0 { + if !keypad.is_empty() { events.retain(|e| { - let digit = matches!(e, Event::Key { key: Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 | Key::Num6 | Key::Num7 | Key::Num8 | Key::Num9, modifiers, .. } if modifiers.alt); - if digit && keypad > 0 { - keypad -= 1; - return false; + let Event::Key { key, modifiers, .. } = e else { + return true; + }; + if !modifiers.alt { + return true; + } + // The first keypad key still to take out that egui reports as `key`. + let stands_for = |d: &usize| DIGIT_KEYS.get(*d) == Some(key) || NUMLOCK_OFF_EGUI_KEYS.get(*d) == Some(key); + match keypad.iter().position(stands_for) { + Some(i) => { + keypad.remove(i); + false + } + None => true, } - true }); } if !self.typed.is_empty() { @@ -129,7 +174,7 @@ pub fn msg_hook(msg: *const std::ffi::c_void) { if (WM_KEYDOWN..=WM_SYSCHAR).contains(&msg.message) { CODES.with(|c| { if let Ok(mut c) = c.try_borrow_mut() { - c.message(msg.message, msg.wParam.0); + c.message(msg.message, msg.wParam.0, msg.lParam.0); } }); } @@ -152,14 +197,14 @@ mod tests { /// Alt held, keypad digits `code`, Alt released, and the character Windows then posts. fn alt_code(c: &mut AltCodes, code: &str, posted: u16) { - c.message(WM_SYSKEYDOWN, ALT); + c.message(WM_SYSKEYDOWN, ALT, 0); for d in code.bytes() { let vk = VK_NUMPAD0 + usize::from(d - b'0'); - c.message(WM_SYSKEYDOWN, vk); - c.message(WM_SYSKEYUP, vk); + c.message(WM_SYSKEYDOWN, vk, 0); + c.message(WM_SYSKEYUP, vk, 0); } - c.message(WM_KEYUP, ALT); - c.message(WM_CHAR, usize::from(posted)); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, usize::from(posted), 0); } fn key(key: Key, alt: bool, pressed: bool) -> Event { @@ -186,11 +231,11 @@ mod tests { fn a_character_without_a_code_before_it_is_left_to_winit() { let mut c = AltCodes::default(); // A plain key press's character (winit types it), and Alt pressed and released alone. - c.message(WM_KEYDOWN, 0x41); - c.message(WM_CHAR, usize::from(b'a')); - c.message(WM_SYSKEYDOWN, ALT); - c.message(WM_KEYUP, ALT); - c.message(WM_CHAR, usize::from(b'b')); + c.message(WM_KEYDOWN, 0x41, 0); + c.message(WM_CHAR, usize::from(b'a'), 0); + c.message(WM_SYSKEYDOWN, ALT, 0); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, usize::from(b'b'), 0); let mut events = vec![]; c.drain(&mut events); assert!(events.is_empty(), "{events:?}"); @@ -199,12 +244,12 @@ mod tests { #[test] fn another_key_while_alt_is_held_cancels_the_code() { let mut c = AltCodes::default(); - c.message(WM_SYSKEYDOWN, ALT); - c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1); + c.message(WM_SYSKEYDOWN, ALT, 0); + c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1, 0); // Alt+F: a shortcut, not a code. - c.message(WM_SYSKEYDOWN, 0x46); - c.message(WM_KEYUP, ALT); - c.message(WM_CHAR, 0x41); + c.message(WM_SYSKEYDOWN, 0x46, 0); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, 0x41, 0); let mut events = vec![]; c.drain(&mut events); assert!(!events.iter().any(|e| matches!(e, Event::Text(_))), "{events:?}"); @@ -213,13 +258,81 @@ mod tests { #[test] fn a_code_above_the_basic_plane_arrives_as_two_halves() { let mut c = AltCodes::default(); - c.message(WM_SYSKEYDOWN, ALT); - c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1); - c.message(WM_KEYUP, ALT); - c.message(WM_CHAR, 0xD83D); - c.message(WM_CHAR, 0xDE00); + c.message(WM_SYSKEYDOWN, ALT, 0); + c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1, 0); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, 0xD83D, 0); + c.message(WM_CHAR, 0xDE00, 0); let mut events = vec![]; c.drain(&mut events); assert_eq!(events, vec![Event::Text("😀".into())]); } + + /// Alt held, keypad digits `code` with NumLock off (keypad Insert, End, …, flags without the + /// extended-key bit), Alt released, and the character Windows then posts. + fn alt_code_numlock_off(c: &mut AltCodes, code: &str, posted: u16) { + c.message(WM_SYSKEYDOWN, ALT, 0); + for d in code.bytes() { + let vk = NUMLOCK_OFF_KEYS[usize::from(d - b'0')]; + c.message(WM_SYSKEYDOWN, vk, 0); + c.message(WM_SYSKEYUP, vk, 0); + } + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, usize::from(posted), 0); + } + + #[test] + fn with_numlock_off_the_keypad_keys_type_the_code_and_leave_the_caret_alone() { + let mut c = AltCodes::default(); + alt_code_numlock_off(&mut c, "0176", 0xB0); + // What egui reports for keypad 0, 1, 7 and 6 with NumLock off and Alt held, a press and a + // release each, then a plain Home typed after Alt came up. + let mut events = vec![]; + for k in [Key::Insert, Key::End, Key::Home, Key::ArrowRight] { + events.push(key(k, true, true)); + events.push(key(k, true, false)); + } + events.push(key(Key::Home, false, true)); + c.drain(&mut events); + assert_eq!(events, vec![key(Key::Home, false, true), Event::Text("°".into())]); + } + + #[test] + fn with_numlock_off_keypad_five_counts_too() { + let mut c = AltCodes::default(); + // Keypad 5 is Clear, which egui reports as the top-row 5. 0151 uses Insert, End and Clear; + // 0234 uses Insert, Down, Page Down and Left. + alt_code_numlock_off(&mut c, "0151", 0x2014); + alt_code_numlock_off(&mut c, "0234", 0xEA); + let mut events = + vec![key(Key::Num5, true, true), key(Key::ArrowDown, true, true), key(Key::PageDown, true, true), key(Key::ArrowLeft, true, true)]; + c.drain(&mut events); + assert_eq!(events, vec![Event::Text("—ê".into())]); + } + + #[test] + fn the_separate_navigation_keys_still_move_the_caret_and_cancel_the_code() { + let mut c = AltCodes::default(); + c.message(WM_SYSKEYDOWN, ALT, 0); + c.message(WM_SYSKEYDOWN, NUMLOCK_OFF_KEYS[1], 0); + c.message(WM_SYSKEYUP, NUMLOCK_OFF_KEYS[1], 0); + // Alt+Home on the separate Home key (extended): a shortcut, not a digit. + c.message(WM_SYSKEYDOWN, NUMLOCK_OFF_KEYS[7], EXTENDED_KEY); + c.message(WM_SYSKEYUP, NUMLOCK_OFF_KEYS[7], EXTENDED_KEY); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, 0x41, 0); + let mut events = vec![key(Key::End, true, true), key(Key::End, true, false), key(Key::Home, true, true), key(Key::Home, true, false)]; + c.drain(&mut events); + assert_eq!(events, vec![key(Key::Home, true, true), key(Key::Home, true, false)]); + } + + #[test] + fn numlock_off_keys_without_alt_are_left_alone() { + let mut c = AltCodes::default(); + c.message(WM_KEYDOWN, NUMLOCK_OFF_KEYS[7], 0); + c.message(WM_KEYUP, NUMLOCK_OFF_KEYS[7], 0); + let mut events = vec![key(Key::Home, false, true), key(Key::Home, false, false)]; + c.drain(&mut events); + assert_eq!(events.len(), 2); + } } diff --git a/apps/vectorcraft/tests/it/help.rs b/apps/vectorcraft/tests/it/help.rs index 059b9c476..ece66b98a 100644 --- a/apps/vectorcraft/tests/it/help.rs +++ b/apps/vectorcraft/tests/it/help.rs @@ -54,7 +54,7 @@ fn prints_usage(args: &[&str], env: &[(&str, &str)]) { assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); assert!(out.stderr.is_empty(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); assert!(stdout.starts_with("Usage: vectorcraft "), "{args:?}: {stdout}"); - for flag in ["--control", "--automation-read-root", "--automation-write-root", "--in-window-menus", "--version", "--help"] { + for flag in ["--control", "--automation-read-root", "--automation-write-root", "--in-window-menus", "--safe-mode", "--version", "--help"] { assert!(stdout.contains(flag), "{args:?}: usage doesn't mention {flag}: {stdout}"); } // Headless work is the CLI's: the usage says where to go. @@ -74,3 +74,27 @@ fn help_prints_usage_and_exits_before_startup() { fn help_wins_over_a_bad_control_port_variable() { prints_usage(&["--help"], &[("VECTORCRAFT_CONTROL_PORT", "abc")]); } + +/// An option the app doesn't know (a typo of `--safe-mode`) is an error, not a file to open with +/// plug-ins; a later `--help` still wins. +#[test] +fn an_unknown_option_is_an_error_and_not_a_file() { + for args in [&["--safe-mod"][..], &["drawing.svg", "--safe-mod"], &["-x"]] { + let out = run(args, &[]); + assert!(!out.status.success(), "{args:?}"); + assert!(out.stdout.is_empty(), "{args:?}"); + let stderr = String::from_utf8(out.stderr).unwrap(); + let unknown = args.iter().find(|a| a.starts_with('-')).unwrap(); + assert_eq!( + stderr, + format!("vectorcraft: unknown option {unknown}; see vectorcraft --help, or put -- before a file whose name starts with '-'\n"), + "{args:?}" + ); + } + prints_usage(&["--safe-mod", "--help"], &[]); + // After `--`, a name starting with '-' is a file: the run goes on to the control port, which + // `run` makes fail. + let out = run(&["--", "-x.svg"], &[]); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!(stderr.contains("VECTORCRAFT_CONTROL_PORT") && !stderr.contains("unknown option"), "{stderr}"); +} diff --git a/contributors/people.toml b/contributors/people.toml index 69c412f8b..420177ffb 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -220,3 +220,8 @@ real_name = "Sven Lafebre" [people.kylekanouse] real_name = "Kyle Kanouse" sync_github_name = true + +[people.Hassan1305] +real_name = "Hassan Nawaz Khan" +display_name = "Hassan Nawaz Khan" +sync_github_name = false diff --git a/crates/affinity/src/model/read.rs b/crates/affinity/src/model/read.rs index 00910baab..80f4e0993 100644 --- a/crates/affinity/src/model/read.rs +++ b/crates/affinity/src/model/read.rs @@ -29,6 +29,14 @@ const MAX_DOCUMENT_CELLS: usize = 1 << 18; /// Points of all strokes' pressure curves together (a stroke style may be shared by many layers). const MAX_DOCUMENT_PRESSURE_POINTS: usize = 1 << 20; +/// Child references inspected, page rectangles produced, and outline segments retained, in all +/// (shared objects are visited once per link, so each visit is charged). Real documents stay +/// within a few times [`MAX_NODES`]; this leaves ~30x headroom over that. +const MAX_DOCUMENT_WORK: usize = 1 << 24; + +/// Owned layer-name characters kept in all (each visit of a shared object keeps its own copy). +const MAX_DOCUMENT_NAME_CHARS: usize = 16 << 20; + /// What a whole read may still use. Shared objects can be linked from many layers, and embedded /// documents are read with what their embedding document left, so linking or embedding the /// same content many times can't multiply the work. @@ -44,6 +52,10 @@ pub(crate) struct Budget { pub(crate) cells: usize, /// Points of pressure curves ([`MAX_DOCUMENT_PRESSURE_POINTS`] in all). pub(crate) pressure: usize, + /// Child references, page rectangles and outline segments ([`MAX_DOCUMENT_WORK`] in all). + pub(crate) work: usize, + /// Layer-name characters ([`MAX_DOCUMENT_NAME_CHARS`] in all). + pub(crate) name_chars: usize, } impl Budget { @@ -53,7 +65,15 @@ impl Budget { chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS, pressure: MAX_DOCUMENT_PRESSURE_POINTS, + work: MAX_DOCUMENT_WORK, + name_chars: MAX_DOCUMENT_NAME_CHARS, }; + + /// Take `n` units of work, or fail the read. + pub(crate) fn spend(&mut self, n: usize) -> Result<(), Error> { + self.work = self.work.checked_sub(n).ok_or(Error::Limit("too much shared geometry or page data"))?; + Ok(()) + } } /// Read a document: archive, `doc.dat` and the model. @@ -192,6 +212,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { return Ok(None); } let pages = pages(s, spread); + self.budget.spend(pages.len() + 1)?; let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h }); let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect { x0: 0.0, @@ -214,7 +235,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { let s = self.s; let mut out = Vec::new(); let mut mask = None; - for c in s.objs(parent, b"Chld") { + let kids = s.objs(parent, b"Chld"); + // Invisible masks and skipped children cost no node, so the visit is charged here. + self.budget.spend(kids.len())?; + for c in kids { if s.is(c, b"MRst") { // A mask layer inside a group or layer masks that container. if s.bool(c, b"Visi") == Some(false) { @@ -254,7 +278,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { let world = local.then(parent); let class = s.class(id).unwrap_or(Tag(0)); // A layer name is short; a long one is cut, as every visit of a shared object keeps a copy. - let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS).collect(); + let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS.min(self.budget.name_chars)).collect(); + self.budget.name_chars -= name.chars().count(); let visible = s.bool(id, b"Visi").unwrap_or(true); let locked = s.bool(id, b"Edtb").is_some_and(|e| !e); let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0)); @@ -312,6 +337,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { self.warn(format!("a {} that could not be read", describe(class))); return Ok(Kind::Unsupported); }; + self.budget.spend(local.subpaths.iter().map(|sp| sp.segments.len() + 1).sum())?; let (fills, strokes) = paint::node_paint(self, id, world); Kind::Shape { path: local.transformed(world), fills, strokes, even_odd } } @@ -645,3 +671,15 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { Kind::Artboard { rect, path: path.transformed(world), even_odd, background, strokes } } } + +#[cfg(test)] +mod budget_tests { + use super::*; + + #[test] + fn shared_work_is_bounded() { + let mut b = Budget::FULL; + assert!(b.spend(MAX_DOCUMENT_WORK).is_ok()); + assert_eq!(b.spend(1), Err(Error::Limit("too much shared geometry or page data"))); + } +} diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs index 12102581e..c7b2acbbd 100644 --- a/crates/affinity/src/paint.rs +++ b/crates/affinity/src/paint.rs @@ -238,12 +238,13 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option> = match s.field(grad, b"Posn") { - Some(Value::Array(a)) => a.iter().filter_map(floats).collect(), - _ => Vec::new(), + // Convert only the positions that pair with a colour, as shared attributes repeat this per run. + let posn: &[Value] = match s.field(grad, b"Posn") { + Some(Value::Array(a)) => a, + _ => &[], }; let mut stops = Vec::new(); - for (c, p) in cols.iter().zip(&posn).take(MAX_STOPS) { + for (c, p) in cols.iter().zip(posn.iter().filter_map(floats)).take(MAX_STOPS) { let color = color(r, *c)?; let offset = p.first().copied().filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0); let bias = p.get(1).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0); diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs index d458c08a7..bb8c1c88b 100644 --- a/crates/affinity/src/raster.rs +++ b/crates/affinity/src/raster.rs @@ -195,6 +195,11 @@ fn decode_within(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), worl if let Some(name) = s.entry(bitmap, b"Bckg") { let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + // The whole source is decoded for every layer that reads it, so charge it all, not only the crop. + let Some(left) = r.budget.pixels.checked_sub(u64::from(width) * u64::from(height)) else { + return Err(Error::Limit("embedded image pixels")); + }; + r.budget.pixels = left; match original(r, name).and_then(|bytes| source_pixels(&bytes, width, height)) { Ok(pixels) => Some((width as usize, pixels)), Err(Error::Limit(e)) => return Err(Error::Limit(e)), @@ -318,8 +323,10 @@ fn grid(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize) -> (usize, usiz let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| width.saturating_mul(bps).div_ceil(TILE)); let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE)); + // A grid larger than the bitmap has tiles no crop can reach: don't convert or scan them on every reference. + let tiles = width.saturating_mul(bps).div_ceil(TILE).saturating_mul(height.div_ceil(TILE)); let states: Vec = match s.field(bitmap, &tag(b"Sta", c)) { - Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(), + Some(Value::Array(a)) => a.iter().take(tiles).map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(), _ => Vec::new(), }; (tw, th, states) diff --git a/crates/affinity/src/text.rs b/crates/affinity/src/text.rs index 1a3b6e544..d2bb55ca0 100644 --- a/crates/affinity/src/text.rs +++ b/crates/affinity/src/text.rs @@ -51,6 +51,9 @@ const MAX_TABLE_LINES: usize = 4096; /// Cells a table may have (rows × columns): each becomes a text object with its edge lines. const MAX_TABLE_CELLS: usize = 1 << 16; +/// Work charged for each copy of a run's formatting a table cell keeps. +const TABLE_RUN_COST: usize = 16; + /// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges. pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option { let s = r.s; @@ -108,9 +111,10 @@ pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option
{ let mut parts = run.text.split(CELL_BREAK).peekable(); while let Some(part) = parts.next() { if !part.is_empty() { + // Each cell keeps its own copy of the run's formatting, so each copy is charged. match target.and_then(|i| cells.get_mut(i)) { - Some(cell) => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }), - None => extra = true, + Some(cell) if r.budget.spend(TABLE_RUN_COST).is_ok() => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }), + _ => extra = true, } } if parts.peek().is_some() { @@ -157,6 +161,13 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec (Vec = Vec::new(); let mut open: Option = None; - for run in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() { + for run in paragraph_runs { let Some(attrs) = s.obj(run, b"Item") else { continue }; let a = paragraph(r, attrs); first_align.get_or_insert(a.align); @@ -238,7 +249,7 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec &'static str { + match self { + Self::Custom => "custom", + Self::Cnc => "cnc", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Custom => "Custom", + Self::Cnc => "CNC / CAM", + } + } + + /// A preset by id (`custom`, `cnc`), any case. + pub fn from_id(s: &str) -> Option { + Self::ALL.into_iter().find(|p| s.trim().eq_ignore_ascii_case(p.id())) + } + + /// The options the preset sets (Custom: the defaults); the region and crop are the defaults. + pub fn options(self) -> DxfOptions { + match self { + Self::Custom => DxfOptions::default(), + Self::Cnc => DxfOptions { + version: DxfVersion::R2000, + unit: Unit::Millimeters, + scale: 1.0, + scale_lineweights: false, + colors: ColorDepth::Aci256, + raster: RasterFormat::Png, + preserve: Preserve::Editability, + alter_paths: false, + outline_text: true, + ..DxfOptions::default() + }, + } + } + + /// The preset `o` matches: CNC / CAM when every option it sets is as it sets it, else Custom. + pub fn of(o: &DxfOptions) -> Self { + let c = Self::Cnc.options(); + let same = o.version == c.version + && o.unit == c.unit + && o.scale == c.scale + && o.scale_lineweights == c.scale_lineweights + && o.colors == c.colors + && o.raster == c.raster + && o.preserve == c.preserve + && o.alter_paths == c.alter_paths + && o.outline_text == c.outline_text; + if same { Self::Cnc } else { Self::Custom } + } +} + /// An image file a drawing links to, to be written next to it under `name`. #[derive(Clone, Debug)] pub struct DxfImage { diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 72fa4f7b3..45ed99299 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -1227,10 +1227,10 @@ impl<'a> Scene<'a> { let Some(&first) = run.first() else { continue }; let lo = run.iter().map(|g| g.byte).min().unwrap_or(first.byte); let hi = run.iter().map(|g| g.byte + g.len).max().unwrap_or(lo); - // A right-to-left paragraph's marker is drawn with its tab first and its number's - // period at the number's left; the TEXT entity holds the characters in that order. + // A right-to-left paragraph's marker is drawn with its number's period at the + // number's left; the TEXT entity holds the characters in that order. let marker = chunk.first().and_then(|(_, _, m)| *m).map(|m| match m.text.strip_suffix('.') { - Some(number) if m.tab == m.glyphs.start => format!(".{number}"), + Some(number) if layout.glyphs.get(m.tab).is_some_and(|g| g.rtl) => format!(".{number}"), _ => m.text.clone(), }); let Some(s) = marker.as_deref().or_else(|| plain.get(lo..hi).map(str::trim_end)).filter(|s| !s.trim().is_empty()) else { continue }; diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index d99858511..90e4cbc2c 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -823,6 +823,30 @@ fn units_scale_coordinates_and_set_insunits() { assert!(export(&d, &DxfOptions { scale: f64::NAN, ..opts(&d) }).is_err()); } +#[test] +fn the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines() { + let mut d = text_doc("Cut", 20.0); + let layer = d.layers[0].id; + let frame = path(&mut d, shapes::rectangle(Rect::new(5.0, 5.0, 195.0, 95.0)), Paint::None, Paint::solid(Color::BLACK), 10.0); + d.insert(Some(layer), usize::MAX, frame).unwrap(); + let o = DxfOptions { region: d.artboards[0].rect, ..DxfPreset::Cnc.options() }; + let out = export(&d, &o).unwrap(); + let p = pairs(&out.bytes); + assert_eq!(header(&p, "$ACADVER").as_deref(), Some("AC1015")); + assert_eq!(header(&p, "$INSUNITS").as_deref(), Some("4")); + let k = kinds(&out); + assert!(!k.iter().any(|k| matches!(k.as_str(), "TEXT" | "MTEXT" | "IMAGE")), "type is paths: {k:?}"); + assert!(k.len() > 2, "the glyphs' outlines: {k:?}"); + // The 10 pt stroke (wider than the widest lineweight) is one center line, not a filled outline. + assert_eq!(k.last().map(String::as_str), Some("LWPOLYLINE"), "{k:?}"); + assert_ne!(k.get(k.len() - 2).map(String::as_str), Some("LWPOLYLINE"), "{k:?}"); + assert!(out.images.is_empty()); + assert_eq!(DxfPreset::of(&o), DxfPreset::Cnc); + assert_eq!(DxfPreset::of(&DxfOptions { colors: ColorDepth::True, ..o.clone() }), DxfPreset::Custom); + assert_eq!(DxfPreset::of(&DxfOptions::default()), DxfPreset::Custom); + assert_eq!(DxfPreset::from_id(" CNC "), Some(DxfPreset::Cnc)); +} + #[test] fn layers_become_dxf_layers() { let mut d = shapes_doc(); @@ -1052,15 +1076,15 @@ fn a_bullet_is_text_of_its_own_before_its_paragraph() { /// A numbered list paragraph's number and period are a TEXT entity of their own where they are /// drawn, in the order they are drawn: in a right-to-left paragraph the period comes first, at the -/// number's left. +/// number's left, with the marker aligned left or right. #[test] fn a_number_is_text_of_its_own_before_its_paragraph() { - use vectorcraft_doc::text::{NumberStyle, ParaList}; - let numbered = |text: &str, count: u32| { + use vectorcraft_doc::text::{ListAlign, NumberStyle, ParaList}; + let numbered_aligned = |text: &str, count: u32, align: ListAlign| { let mut d = text_doc(text, 20.0); let id = d.layers[0].children().unwrap()[0].id; if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { - t.para.list = Some(ParaList::numbered(NumberStyle::Arabic)); + t.para.list = Some(ParaList::numbered(NumberStyle::Arabic).aligned(align)); t.para.left_indent = 30.0; t.para.first_line_indent = -30.0; t.list_count = count; @@ -1068,10 +1092,27 @@ fn a_number_is_text_of_its_own_before_its_paragraph() { let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap(); entities(&out).iter().map(|e| (get(e, 1).map(str::to_string), all(e, 10)[0])).collect::>() }; + let numbered = |text: &str, count: u32| numbered_aligned(text, count, ListAlign::Left); assert_eq!(numbered("Item", 11), [(Some("12.".to_string()), 10.0), (Some("Item".to_string()), 40.0)]); let rtl = numbered("שלום", 11); assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::>(), ["שלום", ".12"]); assert!(rtl[1].1 > rtl[0].1, "the number right of the text: {rtl:?}"); + // Aligned right, the number keeps its order and moves right, against the text. + let right = numbered_aligned("Item", 11, ListAlign::Right); + assert_eq!((right[0].0.as_deref(), right[1].clone()), (Some("12."), (Some("Item".to_string()), 40.0))); + assert!(right[0].1 > 10.0 && right[0].1 < 40.0, "{right:?}"); + let rtl = numbered_aligned("שלום", 11, ListAlign::Right); + assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::>(), ["שלום", ".12"]); + // A mixed paragraph: the period follows the paragraph's direction, not the script of the + // words it holds, with either alignment. + let marker = |text: &str, align: ListAlign| { + let out = numbered_aligned(text, 11, align); + out.into_iter().filter_map(|(s, _)| s).find(|s| s.contains("12")).unwrap_or_default() + }; + for align in [ListAlign::Left, ListAlign::Right] { + assert_eq!(marker("Item שלום", align), "12.", "{align:?}"); + assert_eq!(marker("שלום Item", align), ".12", "{align:?}"); + } } /// Area type that Shrink Text to Fit scales down is written at the height it draws at. diff --git a/crates/color/src/cms/generic.rs b/crates/color/src/cms/generic.rs index 95abdf2f3..3d532ac02 100644 --- a/crates/color/src/cms/generic.rs +++ b/crates/color/src/cms/generic.rs @@ -1,19 +1,26 @@ //! "VectorCraft Generic CMYK (SWOP-like)": our own parametric press model. //! //! We can't redistribute Adobe's or ECI's characterisation profiles, so the built-in CMYK space is -//! a small physical model instead of an ICC LUT. It is *ours* and only SWOP-*like*: the numbers -//! are rounded, typical values for coated web offset, not a measured dataset. +//! a small physical model instead of an ICC LUT. It is *ours* and only SWOP-*like*. Two of its +//! figures are published values for SWOP printing: the tone value increase of each ink at 40% and +//! 50% from CGATS TR 001 (), and the 300% +//! total ink limit (). +//! The paper, solid and overprint colors and the black-generation curve are VectorCraft's own values. //! -//! * **Forward (CMYK → XYZ):** each ink's nominal dot area is widened by a parabolic dot-gain -//! curve (`a + 4·g·a·(1−a)`, i.e. `g` gain at 50%); the effective areas mix the eight CMY -//! Neugebauer primaries with Demichel weights; black is mixed in the same way against -//! "primary + K" colours whose optical densities add but saturate towards a maximum density -//! (normalised so K on paper is exact), so rich blacks don't multiply to an unrealistic depth. +//! * **Forward (CMYK → XYZ):** each ink's nominal dot area `a` is widened by a tone curve +//! `a + 4·g·a·(1−a)·(1 + s·(2a−1))` that passes through the TR 001 tone value increase at 50% +//! (`g`) and 40% (which sets `s`). The effective areas mix the 16 Neugebauer primaries (paper and +//! every solid and overprint of C, M, Y and K, media-relative) with Demichel weights, linearly in +//! XYZ, so a single ink's colorimetric tone value increase (Murray–Davies) is the TR 001 value. //! * **Inverse (Lab → CMYK):** damped, bounded Gauss–Newton on the forward model solves CMY for a -//! fixed K. Black generation is GCR: solve CMY with K = 0, take the grey component -//! `min(C, M, Y)`, map it through the black-generation curve to K, re-solve CMY. Under-colour +//! fixed K. Black generation is GCR: solve CMY with K = 0, take the gray component +//! `min(C, M, Y)`, add [`BlackGeneration::shortfall`] for each L* point by which that solve stays +//! lighter than the target, map the sum through the black curve to K, re-solve CMY. Under-color //! removal enforces the total area coverage (TAC) limit by adding K and re-solving, then scaling -//! CMY as a last resort. +//! CMY as a last resort. The black point, which source black separates to and which displays as +//! black with black-point compensation, is [`BlackGeneration::black_point_k`] black with neutral +//! CMY filling the TAC. With compensation, a build darker than it in every XYZ channel also +//! displays as black (see [`GenericCmyk::display_xyz`]). //! * **Rendering intents:** colorimetric intents map the source white to paper (relative) or not //! (absolute) and clip; perceptual applies black-point compensation plus soft chroma compression //! against a gamut boundary descriptor (segment maxima of the forward model); saturation keeps @@ -24,50 +31,82 @@ use std::sync::OnceLock; use super::Intent; use super::lab::{D50, Lab, delta_e2000, lab_to_xyz, srgb_to_lab, xyz_to_lab, xyz_to_srgb}; -/// Black generation strength. +/// Black generation. #[derive(Clone, Copy, Debug, PartialEq)] pub struct BlackGeneration { - /// Grey component below which no black is generated. - pub start: f32, - /// Maximum black. - pub max_k: f32, - /// Curve exponent (>1 = lighter black in the midtones). - pub gamma: f32, + /// The black curve as `(x, K)` knots with rising `x`, read by linear interpolation. `x` is the + /// gray component of the solve without black plus [`Self::shortfall`] per L* point by which + /// that solve stays lighter than the target. + pub curve: [(f32, f32); 13], + /// Added to the gray component for each L* point the black-free solve stays too light. + pub shortfall: f32, + /// Black of the black point, whose CMY is neutral and fills the TAC. + pub black_point_k: f32, } /// Parameters of the parametric press model. #[derive(Clone, Debug)] pub struct GenericCmykParams { pub name: &'static str, + /// Paper color (absolute Lab, D50). pub paper: Lab, - /// Solid overprints, Demichel order: C, M, CM, Y, CY, MY, CMY. - pub solids: [Lab; 7], - pub black: Lab, - /// Dot gain at 50% per ink (C, M, Y, K). - pub dot_gain: [f32; 4], + /// Media-relative Lab of the solids and overprints, Demichel order (index `c | m<<1 | y<<2 | + /// k<<3`, paper left out): C, M, CM, Y, CY, MY, CMY, then K, CK, MK, CMK, YK, CYK, MYK, CMYK. + pub primaries: [Lab; 15], + /// Tone value increase at 50% per ink (C, M, Y, K). + pub tvi50: [f32; 4], + /// Tone value increase at 40% per ink (C, M, Y, K). + pub tvi40: [f32; 4], /// Total area coverage limit (sum of inks, 1.0 = 100%). pub tac: f32, pub bg: BlackGeneration, } -/// Rounded, typical SWOP-like values (not measured data). +/// The built-in SWOP-like press: TR 001 tone value increase, a 300% ink limit, and VectorCraft's +/// own paper, primaries and black curve. pub fn swop_like() -> GenericCmykParams { GenericCmykParams { name: super::GENERIC_CMYK, paper: Lab::new(93.0, -0.5, 2.5), - solids: [ - Lab::new(55.0, -37.0, -50.0), // C - Lab::new(48.0, 74.0, -3.0), // M - Lab::new(24.0, 22.0, -46.0), // CM (blue) - Lab::new(89.0, -5.0, 93.0), // Y - Lab::new(50.0, -65.0, 27.0), // CY (green) - Lab::new(47.0, 68.0, 48.0), // MY (red) - Lab::new(24.0, 1.0, 0.0), // CMY + primaries: [ + Lab::new(65.7, -42.6, -47.2), // C + Lab::new(55.5, 76.2, -9.4), // M + Lab::new(31.1, 20.3, -49.7), // CM (blue) + Lab::new(96.2, -7.0, 90.1), // Y + Lab::new(61.0, -68.4, 30.1), // CY (green) + Lab::new(55.0, 69.8, 45.0), // MY (red) + Lab::new(28.3, 0.7, -0.5), // CMY + Lab::new(21.5, 0.8, -0.9), // K + Lab::new(15.9, -8.4, -10.0), // CK + Lab::new(14.7, 15.4, -1.0), // MK + Lab::new(11.2, 8.9, -6.6), // CMK + Lab::new(20.2, -2.4, 12.0), // YK + Lab::new(15.7, -14.6, 4.6), // CYK + Lab::new(14.5, 11.2, 5.7), // MYK + Lab::new(11.0, -0.7, 0.1), // CMYK ], - black: Lab::new(19.0, 0.5, 0.5), - dot_gain: [0.20, 0.20, 0.20, 0.22], + tvi50: [0.200, 0.176, 0.168, 0.216], + tvi40: [0.200, 0.173, 0.167, 0.219], tac: 3.0, - bg: BlackGeneration { start: 0.1, max_k: 0.95, gamma: 1.25 }, + bg: BlackGeneration { + curve: [ + (0.0, 0.0), + (0.25, 0.0), + (0.35, 0.006), + (0.45, 0.059), + (0.55, 0.139), + (0.65, 0.23), + (0.75, 0.322), + (0.85, 0.41), + (0.95, 0.511), + (1.05, 0.6), + (1.2, 0.733), + (1.35, 0.858), + (1.5, 1.0), + ], + shortfall: 0.03, + black_point_k: 0.9, + }, } } @@ -83,11 +122,12 @@ const GBD_H: usize = 72; // 5° bins pub struct GenericCmyk { pub params: GenericCmykParams, - /// XYZ of the 8 CMY primaries (index = c | m<<1 | y<<2), without and with K. - prim: [[f32; 3]; 8], - prim_k: [[f32; 3]; 8], + /// Media-relative XYZ of the 16 primaries (index = c | m<<1 | y<<2 | k<<3; 0 is paper). + prim: [[f32; 3]; 16], + /// Shape `s` of each ink's tone curve, from its 40% and 50% tone value increase. + skew: [f32; 4], paper_xyz: [f32; 3], - /// Media-relative XYZ of the darkest printable black (for black-point compensation). + /// Media-relative XYZ of the black point (for black-point compensation). black_rel: [f32; 3], gbd: OnceLock, } @@ -95,37 +135,17 @@ pub struct GenericCmyk { impl GenericCmyk { pub fn new(params: GenericCmykParams) -> Self { let paper_xyz = lab_to_xyz(params.paper); - let mut prim = [[0.0f32; 3]; 8]; - prim[0] = paper_xyz; - for (p, lab) in prim[1..].iter_mut().zip(params.solids.iter()) { - *p = lab_to_xyz(*lab); + let mut prim = [D50; 16]; + for (p, lab) in prim[1..].iter_mut().zip(params.primaries.iter()) { + *p = lab_to_xyz(*lab).map(|v| v.max(1e-6)); } - let k_xyz = lab_to_xyz(params.black); - // Black over other inks: optical densities add, but saturate towards a maximum density - // (ink trapping / surface reflection), normalised so that K on paper is exact. - const D_MAX: f32 = 2.0; - let sat = |d: f32| D_MAX * (1.0 - (-d / D_MAX).exp()); - let mut prim_k = [[0.0f32; 3]; 8]; - for i in 0..8 { - for ch in 0..3 { - let di = -(prim[i][ch] / paper_xyz[ch]).max(1e-6).log10(); - let dk = -(k_xyz[ch] / paper_xyz[ch]).max(1e-6).log10(); - let d = sat(di + dk) * dk / sat(dk).max(1e-6); - prim_k[i][ch] = paper_xyz[ch] * 10f32.powf(-d.max(di)); - } - } - let mut g = Self { params, prim, prim_k, paper_xyz, black_rel: [0.0; 3], gbd: OnceLock::new() }; - // Darkest black within TAC: search a few rich blacks. - let mut best = [1.0f32; 3]; - for k in [0.8f32, 0.9, 1.0] { - let rest = (g.params.tac - k).max(0.0) / 3.0; - let cmy = rest.min(1.0); - let x = g.to_xyz_rel([cmy, cmy, cmy, k]); - if x[1] < best[1] { - best = x; - } - } - g.black_rel = best; + // The curve's increase at 40% is 0.96·g·(1 − 0.2·s), so `s` follows from the 40% value. + let skew = [0, 1, 2, 3].map(|i| { + let g = params.tvi50[i]; + if g > 1e-6 { (1.0 - params.tvi40[i] / (0.96 * g)) / 0.2 } else { 0.0 } + }); + let mut g = Self { params, prim, skew, paper_xyz, black_rel: [0.0; 3], gbd: OnceLock::new() }; + g.black_rel = g.to_xyz_rel(g.black_point_build()); g } @@ -133,35 +153,81 @@ impl GenericCmyk { self.params.name } - fn gain(a: f32, g: f32) -> f32 { - let a = a.clamp(0.0, 1.0); - (a + 4.0 * g * a * (1.0 - a)).clamp(0.0, 1.0) + /// The black point: [`BlackGeneration::black_point_k`] black, with C, M and Y + /// chosen so that they fill the TAC and the color is neutral (a* = b* = 0). + pub fn black_point_build(&self) -> [f32; 4] { + let k = self.params.bg.black_point_k.clamp(0.0, 1.0); + let rest = (self.params.tac - k).clamp(0.0, 3.0); + let build = |c: f32, m: f32| [c, m, (rest - c - m).clamp(0.0, 1.0), k]; + let ab = |c: f32, m: f32| { + let l = self.to_lab_rel(build(c, m)); + [l.a, l.b] + }; + let lo = (rest - 2.0).max(0.0); + let hi = rest.min(1.0); + let (mut c, mut m) = ((rest / 3.0).clamp(lo, hi), (rest / 3.0).clamp(lo, hi)); + for _ in 0..30 { + let r = ab(c, m); + if r[0].abs() + r[1].abs() < 1e-4 { + break; + } + let h = 1e-3; + let rc = ab(c - h, m); + let rm = ab(c, m - h); + let j = [[(r[0] - rc[0]) / h, (r[0] - rm[0]) / h], [(r[1] - rc[1]) / h, (r[1] - rm[1]) / h]]; + let det = j[0][0] * j[1][1] - j[0][1] * j[1][0]; + if det.abs() < 1e-9 { + break; + } + let dc = (-r[0] * j[1][1] + r[1] * j[0][1]) / det; + let dm = (-r[1] * j[0][0] + r[0] * j[1][0]) / det; + c = (c + dc.clamp(-0.1, 0.1)).clamp(lo, hi); + m = (m + dm.clamp(-0.1, 0.1)).clamp(lo, hi); + // Yellow takes the rest, so keep it within 0..1 as well. + let y = rest - c - m; + if y > 1.0 { + c += (y - 1.0) / 2.0; + m += (y - 1.0) / 2.0; + } else if y < 0.0 { + c += y / 2.0; + m += y / 2.0; + } + c = c.clamp(lo, hi); + m = m.clamp(lo, hi); + } + build(c.clamp(lo, hi), m.clamp(lo, hi)) + } + + /// Effective dot area of ink `i` at nominal area `a`: the TR 001-style tone curve. + fn gain(&self, i: usize, a: f32) -> f32 { + let a = if a.is_finite() { a.clamp(0.0, 1.0) } else { 0.0 }; + let g = self.params.tvi50[i]; + (a + 4.0 * g * a * (1.0 - a) * (1.0 + self.skew[i] * (2.0 * a - 1.0))).clamp(0.0, 1.0) } /// CMYK (0..1) → absolute XYZ (D50). pub fn to_xyz(&self, cmyk: [f32; 4]) -> [f32; 3] { - let dg = self.params.dot_gain; - let c = Self::gain(cmyk[0], dg[0]); - let m = Self::gain(cmyk[1], dg[1]); - let y = Self::gain(cmyk[2], dg[2]); - let k = Self::gain(cmyk[3], dg[3]); - let mut out = [0.0f32; 3]; - for i in 0..8 { - let w = (if i & 1 != 0 { c } else { 1.0 - c }) * (if i & 2 != 0 { m } else { 1.0 - m }) * (if i & 4 != 0 { y } else { 1.0 - y }); - if w == 0.0 { - continue; - } - for (ch, o) in out.iter_mut().enumerate() { - *o += w * ((1.0 - k) * self.prim[i][ch] + k * self.prim_k[i][ch]); - } - } - out + let x = self.to_xyz_rel(cmyk); + [x[0] * self.paper_xyz[0] / D50[0], x[1] * self.paper_xyz[1] / D50[1], x[2] * self.paper_xyz[2] / D50[2]] } /// Media-relative XYZ (paper = D50 white). pub fn to_xyz_rel(&self, cmyk: [f32; 4]) -> [f32; 3] { - let x = self.to_xyz(cmyk); - [x[0] * D50[0] / self.paper_xyz[0], x[1] * D50[1] / self.paper_xyz[1], x[2] * D50[2] / self.paper_xyz[2]] + let e = [0, 1, 2, 3].map(|i| self.gain(i, cmyk[i])); + let mut out = [0.0f32; 3]; + for (i, p) in self.prim.iter().enumerate() { + let mut w = 1.0; + for (bit, a) in e.iter().enumerate() { + w *= if i & (1 << bit) != 0 { *a } else { 1.0 - a }; + } + if w == 0.0 { + continue; + } + for (o, v) in out.iter_mut().zip(p) { + *o += w * v; + } + } + out } pub fn to_lab_rel(&self, cmyk: [f32; 4]) -> Lab { @@ -174,7 +240,9 @@ impl GenericCmyk { /// Display XYZ: media-relative (paper shows as white) or absolute (simulate paper colour). /// With `bpc` (media-relative only) the press black displays as black, undoing the /// compensation [`Self::gamut_map`] applies on the way in, so sRGB black separated to the - /// darkest rich black shows as black again. + /// black point shows as black again. Each XYZ channel is clamped at 0 on its own, so a build + /// darker than the black point in every channel shows as black, and one darker in only some + /// channels shows as a colored near-black. pub fn display_xyz(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] { if absolute { return self.to_xyz(cmyk); @@ -192,7 +260,7 @@ impl GenericCmyk { xyz_to_srgb(self.display_xyz(cmyk, absolute, bpc)) } - /// Lab of the darkest printable black (media-relative). + /// Lab of the black point (media-relative), see [`Self::black_point_build`]. pub fn black_point(&self) -> Lab { xyz_to_lab(self.black_rel) } @@ -283,12 +351,21 @@ impl GenericCmyk { rgb.map(|v| ((1.0 - v - k) / d).clamp(0.0, 1.0)) } - fn black_for_grey(&self, grey: f32) -> f32 { - let bg = self.params.bg; - if grey <= bg.start { - return 0.0; + /// Black for `x` on the black curve (see [`BlackGeneration::curve`]). + fn black_for(&self, x: f32) -> f32 { + let curve = &self.params.bg.curve; + let mut prev = curve[0]; + if x <= prev.0 { + return prev.1.clamp(0.0, 1.0); } - (((grey - bg.start) / (1.0 - bg.start)).clamp(0.0, 1.0).powf(bg.gamma) * bg.max_k).clamp(0.0, 1.0) + for &(kx, kk) in &curve[1..] { + if x <= kx { + let t = if kx > prev.0 { (x - prev.0) / (kx - prev.0) } else { 1.0 }; + return (prev.1 + (kk - prev.1) * t).clamp(0.0, 1.0); + } + prev = (kx, kk); + } + prev.1.clamp(0.0, 1.0) } /// Separate a media-relative Lab (already gamut-mapped) into CMYK with GCR/UCR and the TAC limit. @@ -297,13 +374,10 @@ impl GenericCmyk { return [0.0; 4]; } let cmy0 = self.solve_cmy(target, 0.0, Self::initial_cmy(target, 0.0)); - let grey = cmy0[0].min(cmy0[1]).min(cmy0[2]); - let mut k = self.black_for_grey(grey); - // If CMY alone can't get dark enough, black must carry the rest. + let gray = cmy0[0].min(cmy0[1]).min(cmy0[2]); + // When CMY alone can't get dark enough, black carries the rest. let l0 = self.to_lab_rel([cmy0[0], cmy0[1], cmy0[2], 0.0]).l; - if l0 > target.l + 1.0 { - k = k.max(((l0 - target.l) / l0.max(1.0)).clamp(0.0, 1.0)); - } + let mut k = self.black_for(gray + self.params.bg.shortfall * (l0 - target.l).max(0.0)); let mut cmy = if k > 0.0 { self.solve_cmy(target, k, Self::initial_cmy(target, k)) } else { cmy0 }; // UCR: trade CMY for K until within the TAC limit. for _ in 0..6 { diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index ecb4c1c59..57709abe6 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -245,8 +245,8 @@ fn loads_system_cmyk_icc() { assert_eq!(c.cmyk_to_srgb([0.0; 4], false).map(|v| (v * 255.0).round() as u8), [255, 255, 255]); } -/// With black-point compensation, sRGB black separates to the press's darkest black and that black -/// displays as black again (hex 000000 -> C67 M67 Y67 K100 -> 000000, not a dark grey). +/// With black-point compensation, sRGB black separates to the press's black point and that black +/// displays as black again (hex 000000 -> C75 M68 Y67 K90 -> 000000, not a dark gray). #[test] fn generic_cmyk_black_round_trips_with_bpc() { let c = cms(); @@ -259,25 +259,104 @@ fn generic_cmyk_black_round_trips_with_bpc() { assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}"); } -/// A color whose solve holds cyan at 0 separates to the same inks when its values change by a few -/// float steps: hex ed1e79 with its red stepped one `f32` at a time over 4,001 values (about 0.00024 -/// in all) stays within 0.001 on every ink. +/// Each ink's 40% and 50% tints gain like CGATS TR 001 (SWOP): the colorimetric tone value increase +/// (Murray–Davies on X for cyan, Y for magenta and black, Z for yellow) is C 20.0, M 17.6, Y 16.8 and +/// K 21.6 points at 50%, and C 20.0, M 17.3, Y 16.7 and K 21.9 points at 40%. +#[test] +fn generic_cmyk_tints_gain_like_tr001() { + let g = generic(); + let tvi = |ink: usize, a: f32| { + let ch = [0, 1, 2, 1][ink]; + let mut cmyk = [0.0f32; 4]; + cmyk[ink] = a; + let tint = g.to_xyz_rel(cmyk)[ch]; + cmyk[ink] = 1.0; + let solid = g.to_xyz_rel(cmyk)[ch]; + let paper = g.to_xyz_rel([0.0; 4])[ch]; + (paper - tint) / (paper - solid) - a + }; + for (ink, (at50, at40)) in [(0.200, 0.200), (0.176, 0.173), (0.168, 0.167), (0.216, 0.219)].into_iter().enumerate() { + assert!((tvi(ink, 0.5) - at50).abs() < 0.002, "ink {ink} at 50%: {}", tvi(ink, 0.5)); + assert!((tvi(ink, 0.4) - at40).abs() < 0.002, "ink {ink} at 40%: {}", tvi(ink, 0.4)); + } +} + +/// More of any ink never makes a Generic CMYK color lighter, in any XYZ channel: on a 10% grid of +/// builds within the 300% ink limit, adding 10% of one ink keeps X, Y and Z or lowers them. +#[test] +fn generic_cmyk_more_ink_is_never_lighter() { + let g = generic(); + let steps = (0..=10).map(|i| i as f32 / 10.0); + for c in steps.clone() { + for m in steps.clone() { + for y in steps.clone() { + for k in steps.clone() { + let base = [c, m, y, k]; + if base.iter().sum::() > 3.0 - 0.1 + 1e-4 { + continue; + } + let x0 = g.to_xyz_rel(base); + for ink in 0..4 { + if base[ink] >= 1.0 { + continue; + } + let mut more = base; + more[ink] += 0.1; + let x1 = g.to_xyz_rel(more); + assert!((0..3).all(|ch| x1[ch] <= x0[ch] + 1e-5), "{base:?} plus ink {ink}: {x0:?} to {x1:?}"); + } + } + } + } + } +} + +/// sRGB black separates to 90% black under neutral CMY that fills the 300% ink limit, light grays +/// carry no black, and black rises steadily from the light end of the gray ramp to the dark end. +#[test] +fn generic_cmyk_black_generation_follows_the_black_curve() { + let c = cms(); + let black = c.srgb_to_cmyk([0.0; 3], Intent::RelativeColorimetric); + assert!((black[3] - 0.9).abs() < 0.005, "{black:?}"); + assert!((black.iter().sum::() - 3.0).abs() < 0.01, "{black:?}"); + let lab = c.cmyk_to_lab(black); + assert!(lab.a.abs() < 0.05 && lab.b.abs() < 0.05, "neutral: {lab:?}"); + let light = c.srgb_to_cmyk([0.7; 3], Intent::RelativeColorimetric); + assert_eq!(light[3], 0.0, "{light:?}"); + let mut prev = 0.0; + for v in (0..=50).rev().map(|i| i as f32 / 50.0) { + let k = c.srgb_to_cmyk([v; 3], Intent::RelativeColorimetric)[3]; + assert!(k >= prev - 1e-4, "gray {v}: K {k} after {prev}"); + prev = k; + } +} + +/// A color whose solve holds an ink at a bound separates to the same inks when its values change by +/// a few float steps: hex f01482 (cyan held at 0) and hex ed1e79 (magenta held at 100%), each with +/// its red stepped one `f32` at a time over 4,001 values (about 0.00024 in all), stay within 0.001 +/// on every ink. #[test] fn generic_cmyk_separates_nearby_colors_at_the_gamut_edge_alike() { let c = cms(); - let pink = [237.0f32 / 255.0, 30.0 / 255.0, 121.0 / 255.0]; - let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]); - for step in -2000i32..=2000 { - let mut rgb = pink; - rgb[0] = f32::from_bits(pink[0].to_bits().wrapping_add_signed(step)); - let ink = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric); - for i in 0..4 { - lo[i] = lo[i].min(ink[i]); - hi[i] = hi[i].max(ink[i]); + let spread = |pink: [f32; 3]| { + let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]); + for step in -2000i32..=2000 { + let mut rgb = pink; + rgb[0] = f32::from_bits(pink[0].to_bits().wrapping_add_signed(step)); + let ink = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric); + for i in 0..4 { + lo[i] = lo[i].min(ink[i]); + hi[i] = hi[i].max(ink[i]); + } } - } + (lo, hi) + }; + let (lo, hi) = spread([240.0f32 / 255.0, 20.0 / 255.0, 130.0 / 255.0]); assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}"); assert_eq!(lo[0], 0.0, "{lo:?}"); + let (lo, hi) = spread([237.0f32 / 255.0, 30.0 / 255.0, 121.0 / 255.0]); + assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}"); + assert_eq!(hi[1], 1.0, "{hi:?}"); } /// A color whose solve holds cyan and magenta at 1 separates to the same inks when its values change diff --git a/crates/color/src/freeform.rs b/crates/color/src/freeform.rs index 798367ba4..11884d5c6 100644 --- a/crates/color/src/freeform.rs +++ b/crates/color/src/freeform.rs @@ -395,6 +395,15 @@ impl Source { } } +/// The centers of a `cols` × `rows` grid of cells over `b`, row by row. +fn cell_centers(b: Rect, cols: u16, rows: u16) -> impl Iterator { + let (cw, ch) = (b.width() / cols as f64, b.height() / rows as f64); + (0..rows as usize * cols as usize).map(move |k| { + let (i, j) = (k % cols as usize, k / cols as usize); + Point::new(b.x0 + (i as f64 + 0.5) * cw, b.y0 + (j as f64 + 0.5) * ch) + }) +} + /// A freeform gradient ready to sample ([`Freeform::field`]). #[derive(Clone, Debug)] pub struct Field { @@ -429,11 +438,7 @@ impl Field { /// Samples at the centres of a `cols` × `rows` grid of cells over `b`, row by row: RGB and /// opacity (as [`Self::sample`]). pub fn grid(&self, b: Rect, cols: u16, rows: u16) -> impl Iterator + '_ { - let (cw, ch) = (b.width() / cols as f64, b.height() / rows as f64); - (0..rows as usize * cols as usize).map(move |k| { - let (i, j) = (k % cols as usize, k / cols as usize); - self.sample(Point::new(b.x0 + (i as f64 + 0.5) * cw, b.y0 + (j as f64 + 0.5) * ch)) - }) + cell_centers(b, cols, rows).map(move |p| self.sample(p)) } /// Display RGB and opacity at `p`. @@ -503,6 +508,27 @@ impl ModelField { }; (c, a) } + + /// [`Self::sample`] at the centers of a `cols` × `rows` grid of cells over `b`, row by row. + pub fn grid(&self, b: Rect, cols: u16, rows: u16) -> impl Iterator + '_ { + cell_centers(b, cols, rows).map(move |p| self.sample(p)) + } + + /// Display RGB and opacity at `p`: the display color of [`Self::sample`], or the display RGB + /// the points mix in when their models differ. + pub fn display_sample(&self, p: Point) -> ([f32; 3], f32) { + if self.model.is_none() { + return self.first.sample(p); + } + let (c, a) = self.sample(p); + (c.to_rgb(), a) + } + + /// [`Self::display_sample`] at the centers of a `cols` × `rows` grid of cells over `b`, row by + /// row. + pub fn display_grid(&self, b: Rect, cols: u16, rows: u16) -> impl Iterator + '_ { + cell_centers(b, cols, rows).map(move |p| self.display_sample(p)) + } } #[cfg(test)] @@ -530,6 +556,22 @@ mod tests { a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4) } + #[test] + fn the_model_field_grid_and_display_samples_match_its_sample() { + let mut f = two(0.0); + (f.points[0].color, f.points[1].color) = (Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0)); + let field = f.model_field(50.0); + let b = Rect::new(0.0, 0.0, 100.0, 20.0); + let (cells, shown): (Vec<_>, Vec<_>) = (field.grid(b, 5, 1).collect(), field.display_grid(b, 5, 1).collect()); + let (mid, a) = field.sample(Point::new(50.0, 10.0)); + assert_eq!(cells[2], (mid, a)); + assert_eq!(shown[2], (mid.to_rgb(), a)); + // Points in different models give their display mix. + (f.points[0].color, f.points[1].color) = (red(), Color::gray(0.5)); + let (rgb, _) = f.model_field(50.0).display_sample(Point::new(50.0, 0.0)); + assert!(close(rgb, [0.75, 0.25, 0.25]), "{rgb:?}"); + } + #[test] fn the_sample_at_a_point_is_its_colour() { let f = two(0.0); diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 5de064d6b..03e7dc10e 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -143,38 +143,11 @@ impl Color { } /// HSB with hue in degrees 0..360, saturation and brightness 0..1. pub fn to_hsb(&self) -> [f32; 3] { - let [r, g, b] = self.to_rgb(); - let max = r.max(g).max(b); - let min = r.min(g).min(b); - let d = max - min; - let h = if d <= 0.0 { - 0.0 - } else if max == r { - 60.0 * (((g - b) / d).rem_euclid(6.0)) - } else if max == g { - 60.0 * ((b - r) / d + 2.0) - } else { - 60.0 * ((r - g) / d + 4.0) - }; - // f32 rounding in rem_euclid can yield exactly 360; wrap back to 0. - let h = if h >= 360.0 { 0.0 } else { h }; - let s = if max <= 0.0 { 0.0 } else { d / max }; - [h, s, max] + rgb_to_hsb(self.to_rgb()) } pub fn from_hsb(h: f32, s: f32, v: f32) -> Self { - let h = h.rem_euclid(360.0) / 60.0; - let c = v * s; - let x = c * (1.0 - (h % 2.0 - 1.0).abs()); - let (r, g, b) = match h as u32 { - 0 => (c, x, 0.0), - 1 => (x, c, 0.0), - 2 => (0.0, c, x), - 3 => (0.0, x, c), - 4 => (x, 0.0, c), - _ => (c, 0.0, x), - }; - let m = v - c; - Color::Rgb { r: r + m, g: g + m, b: b + m } + let [r, g, b] = hsb_to_rgb([h, s, v]); + Color::Rgb { r, g, b } } /// `#rrggbb` (display RGB). pub fn to_hex(&self) -> String { @@ -327,6 +300,45 @@ impl Color { } } +/// HSB (hue in degrees 0..360, saturation and brightness 0..1) of RGB numbers 0..1, with no color +/// conversion ([`Color::to_hsb`] reads the display color first). +pub fn rgb_to_hsb([r, g, b]: [f32; 3]) -> [f32; 3] { + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let d = max - min; + let h = if d <= 0.0 { + 0.0 + } else if max == r { + 60.0 * (((g - b) / d).rem_euclid(6.0)) + } else if max == g { + 60.0 * ((b - r) / d + 2.0) + } else { + 60.0 * ((r - g) / d + 4.0) + }; + // f32 rounding in rem_euclid can yield exactly 360; wrap back to 0. + let h = if h >= 360.0 { 0.0 } else { h }; + let s = if max <= 0.0 { 0.0 } else { d / max }; + [h, s, max] +} + +/// RGB numbers 0..1 of HSB `[h, s, v]` (hue in degrees, saturation and brightness 0..1): the inverse +/// of [`rgb_to_hsb`]. +pub fn hsb_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] { + let h = h.rem_euclid(360.0) / 60.0; + let c = v * s; + let x = c * (1.0 - (h % 2.0 - 1.0).abs()); + let (r, g, b) = match h as u32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + let m = v - c; + [r + m, g + m, b + m] +} + /// `new` expressed in the colour model of `orig` ([`Color::in_model`]): results of colour operations /// (harmonies, blends, inversions, recolouring) keep the model of the colour they came from. pub fn keep_model(orig: Color, new: Color) -> Color { diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 81b9aaaf5..3ac145fd2 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -296,6 +296,28 @@ impl Appearance { scale(e, s); } } + /// The id of Effect › SVG Filters' effect, whose `origin` parameter is a point of the document. + pub const SVG_FILTER: &str = "svg.filter"; + + /// Does any effect (the object's or a fill's or stroke's) hold an SVG filter? + pub fn has_svg_filter(&self) -> bool { + self.effects.iter().chain(self.items.iter().flat_map(|i| i.effects().iter())).any(|e| e.id == Self::SVG_FILTER) + } + + /// Move the `origin` of every SVG filter effect (where its user space starts in the document) + /// by `a`, as the object it is on moves. + pub fn transform_svg_filters(&mut self, a: kurbo::Affine) { + let items = self.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut()); + for e in self.effects.iter_mut().chain(items).filter(|e| e.id == Self::SVG_FILTER) { + let at = + |i: usize| e.params.get("origin").and_then(|o| o.get(i)).and_then(serde_json::Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0); + let p = a * kurbo::Point::new(at(0), at(1)); + if p.x.is_finite() && p.y.is_finite() && e.params.is_object() { + e.params["origin"] = serde_json::json!([p.x, p.y]); + } + } + } + /// Gradient paints of the fills and strokes. fn gradients_mut(&mut self) -> impl Iterator { self.items.iter_mut().filter_map(|i| match i { @@ -354,6 +376,11 @@ impl Appearance { pub fn has_placed_gradient(&self) -> bool { self.has_gradient(true) } + /// Does a fill or stroke carry a gradient that is drawn at a place in document space: a placed + /// one, or a freeform gradient with points of its own whose vector is fitted to the bounds? + pub fn has_positioned_gradient(&self) -> bool { + self.gradients().any(|g| g.is_placed() || g.has_freeform_points()) + } /// Fix unplaced gradients to their fit on `bounds` (the object's geometric bounds; strokes fit /// the stroke-inflated box), so they can follow transforms that refitting wouldn't reproduce. pub fn pin_gradients(&mut self, bounds: vectorcraft_geom::Rect) { diff --git a/crates/doc/src/artboard.rs b/crates/doc/src/artboard.rs index 6d5d50d4d..d7bfcafca 100644 --- a/crates/doc/src/artboard.rs +++ b/crates/doc/src/artboard.rs @@ -106,6 +106,60 @@ impl super::Document { d.layers.insert(0, Arc::new(layer)); Some(d) } + + /// Where an artboard the size of artboard `i` goes on `side` of it, [`ARTBOARD_GAP`] away, + /// when there is room for it there: every side within [`crate::MAX_COORD`] of the origin and + /// no other artboard overlapping it (touching edges don't overlap). `None` when there is no + /// such artboard or no room. + pub fn room_beside_artboard(&self, i: usize, side: ArtboardSide) -> Option { + let src = self.artboards.get(i)?.rect; + let r = src + side.offset(src); + let in_canvas = [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD); + let clear = self.artboards.iter().all(|a| !(r.x0 < a.rect.x1 && a.rect.x0 < r.x1 && r.y0 < a.rect.y1 && a.rect.y0 < r.y1)); + (in_canvas && clear).then_some(r) + } +} + +/// The space between an artboard and one added or duplicated beside it (VectorCraft's own value, +/// the space New Artboard and Duplicate Artboards leave right of the last artboard). +pub const ARTBOARD_GAP: f64 = 20.0; + +/// A side of an artboard, where an artboard added or duplicated beside it goes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ArtboardSide { + Left, + Right, + Top, + Bottom, +} + +impl ArtboardSide { + pub const ALL: [Self; 4] = [Self::Top, Self::Right, Self::Bottom, Self::Left]; + + /// The side a command's `side` param names: "left", "right", "top" or "bottom". + pub fn parse(name: &str) -> Option { + Self::ALL.into_iter().find(|s| s.name() == name) + } + + pub fn name(self) -> &'static str { + match self { + Self::Left => "left", + Self::Right => "right", + Self::Top => "top", + Self::Bottom => "bottom", + } + } + + /// How far a copy of `r` moves to sit on this side of it, [`ARTBOARD_GAP`] away (y down). + pub fn offset(self, r: Rect) -> vectorcraft_geom::Vec2 { + let (w, h) = (r.width() + ARTBOARD_GAP, r.height() + ARTBOARD_GAP); + match self { + Self::Left => vectorcraft_geom::Vec2::new(-w, 0.0), + Self::Right => vectorcraft_geom::Vec2::new(w, 0.0), + Self::Top => vectorcraft_geom::Vec2::new(0.0, -h), + Self::Bottom => vectorcraft_geom::Vec2::new(0.0, h), + } + } } /// A document's locked artboards ([`Document::locked_artboards`]). diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index 3c4451d54..f17783b1e 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -11,12 +11,16 @@ use serde::{Deserialize, Serialize}; use crate::{Document, NodeId}; /// One asset: what it is called (its files' names) and the objects it exports, cropped to their -/// visual bounds. +/// visual bounds, or for a 3D asset its object's solids as a mesh file. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ExportAsset { pub id: u64, pub name: String, pub nodes: Vec, + /// A 3D asset's mesh format (`usda`, `usdz`, `gltf` or `obj`): its object exports as a 3D + /// file. `None` for art exported as pictures. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format_3d: Option, } /// What the assets are named when they aren't named after their object: `Asset 1`, `Asset 2`… @@ -30,7 +34,16 @@ impl Document { /// The name of the next unnamed asset: one more than the highest `Asset N` so far. pub fn next_asset_name(&self) -> String { - let n = self.assets.iter().filter_map(|a| a.name.strip_prefix(ASSET)?.parse::().ok()).max().unwrap_or(0); + let n = self + .assets + .iter() + .filter_map(|a| { + let digits = a.name.strip_prefix(ASSET)?; + // `parse` alone would accept a leading `+`. + digits.bytes().all(|b| b.is_ascii_digit()).then(|| digits.parse::().ok()).flatten() + }) + .max() + .unwrap_or(0); format!("{ASSET}{}", n.saturating_add(1)) } @@ -57,3 +70,23 @@ impl Document { v.into_iter().map(|(_, id)| id).collect() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn name_after(names: &[&str]) -> String { + let mut doc = Document::new(100.0, 100.0); + for (i, n) in names.iter().enumerate() { + doc.assets.push(ExportAsset { id: i as u64 + 1, name: (*n).to_string(), nodes: vec![], format_3d: None }); + } + doc.next_asset_name() + } + + #[test] + fn next_asset_name_ignores_non_digit_suffixes() { + assert_eq!(name_after(&["Asset +5"]), "Asset 1"); + assert_eq!(name_after(&["Asset ", "Asset 5x"]), "Asset 1"); + assert_eq!(name_after(&["Asset 5"]), "Asset 6"); + } +} diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index bc44b7bf4..5ea0ff9bd 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -19,7 +19,7 @@ use std::f64::consts::PI; use std::sync::Arc; use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint}; -use vectorcraft_geom::kurbo::ParamCurveArclen; +use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveDeriv}; use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2}; use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile}; @@ -904,9 +904,16 @@ pub struct Spine { cum: Vec, anchors: Vec, closed: bool, + /// The subpath the spine was flattened from, kept so [`Spine::at_length`] can read the + /// original curves' tangents. Only the first subpath is stored: the others never affect a + /// spine. + subpath: SubPath, } impl Spine { + /// Build a spine from the first subpath of `path`: flatten it to a polyline, record cumulative + /// arc lengths, and the arc length at each anchor. `None` when `path` has no subpath, or when + /// the first one flattens to fewer than two points (so it has no length to walk). pub fn new(path: &PathData) -> Option { let sp = path.subpaths.first()?; let mut pts: Vec = Vec::new(); @@ -943,8 +950,9 @@ impl Spine { if pts.len() < 2 { return None; } - Some(Self { pts, cum, anchors, closed: sp.closed }) + Some(Self { pts, cum, anchors, closed: sp.closed, subpath: sp.clone() }) } + /// The spine's flattened length in arc length. pub fn length(&self) -> f64 { *self.cum.last().unwrap_or(&0.0) } @@ -1002,20 +1010,32 @@ impl Spine { pub fn at(&self, f: f64) -> (Point, f64) { self.at_length(f.clamp(0.0, 1.0) * self.length()) } - /// Point and tangent angle (radians) at arc length `s` (clamped to the spine). + /// Point and tangent angle (radians) at arc length `s` (clamped to the spine). The point and + /// tangent come from the original curve at that arc length, not from the flattened polyline: a + /// flattened chord's direction is piecewise constant, so steps oriented to a curved spine with + /// `Align to Path` jump between chords instead of following the curve smoothly. pub fn at_length(&self, s: f64) -> (Point, f64) { - let last = self.pts.len().saturating_sub(2); let s = if s.is_finite() { s.clamp(0.0, self.length()) } else { 0.0 }; - let i = match self.cum.binary_search_by(|c| c.total_cmp(&s)) { - Ok(i) => i.min(last), - Err(i) => i.saturating_sub(1).min(last), - }; - let (Some(p0), Some(p1), Some(c0), Some(c1)) = (self.pts.get(i), self.pts.get(i + 1), self.cum.get(i), self.cum.get(i + 1)) else { - return (self.pts.first().copied().unwrap_or_default(), 0.0); - }; - let u = ((s - c0) / (c1 - c0).max(1e-12)).clamp(0.0, 1.0); - let d = *p1 - *p0; - (p0.lerp(*p1, u), d.y.atan2(d.x)) + let sp = &self.subpath; + let n = sp.segment_count(); + let mut start = 0.0; + for i in 0..n { + let c = sp.segment(i); + let end = self.anchor_length(i + 1).unwrap_or(self.length()); + if s <= end || i + 1 == n { + let local_s = (s - start).max(0.0) / (end - start).max(1e-12) * c.arclen(1e-9); + let t = c.inv_arclen(local_s, 1e-9).clamp(0.0, 1.0); + let e = c.eval(t); + let tv = c.deriv().eval(t).to_vec2(); + // Where a handle sits on its anchor the derivative vanishes there: take the + // direction between points a little before and after instead. + let tv = if tv.hypot2() < 1e-24 { c.eval((t + 1e-3).min(1.0)) - c.eval((t - 1e-3).max(0.0)) } else { tv }; + return (Point::new(e.x, e.y), tv.y.atan2(tv.x)); + } + start = end; + } + // Only a spine without segments gets here: the loop returns at the last one. + (self.pts.first().copied().unwrap_or_default(), 0.0) } } @@ -1446,6 +1466,45 @@ mod tests { assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put"); } + #[test] + fn orientations_follow_a_curved_spine_smoothly() { + // A curved spine: from (0, 0) with handles (+50, +50) / (-50, -50) to (100, 0) with handles + // (+50, -50) / (-50, +50) — the classic up-bow arc. + let spine = PathData::single(SubPath::new( + vec![Anchor::smooth(Point::new(0.0, 0.0), Point::new(50.0, 50.0)), Anchor::smooth(Point::new(100.0, 0.0), Point::new(50.0, -50.0))], + false, + )); + let sp = Spine::new(&spine).unwrap(); + // Many steps along the bow: each step's tangent comes from the curve at its arc length, + // not from a flattened chord — so the orientations must sweep the true angle, smoothly. + let len = sp.length(); + let mut prev: Option = None; + for i in 1..=200 { + let (pt, ang) = sp.at_length(len * i as f64 / 201.0); + if let Some(p) = prev { + let step = (ang - p).abs().min(2.0 * std::f64::consts::PI - (ang - p).abs()); + assert!(step < 0.1, "tangent step {} is too big at {} ({}, {})", step, i, pt.x, pt.y); + } + prev = Some(ang); + } + } + + /// Where the spine starts at an anchor whose handle sits on it, the first step still faces along + /// the curve (towards the next handle), not along a direction of zero length. + #[test] + fn a_handle_on_its_anchor_still_gives_the_curve_direction() { + let spine = PathData::single(SubPath::new( + vec![Anchor::corner(Point::new(0.0, 0.0)), Anchor::smooth(Point::new(100.0, 0.0), Point::new(150.0, -50.0))], + false, + )); + let sp = Spine::new(&spine).unwrap(); + let (p, angle) = sp.at_length(0.0); + assert!(p.x.abs() < 1e-6 && p.y.abs() < 1e-6, "{p:?}"); + assert!((angle - std::f64::consts::FRAC_PI_4).abs() < 0.05, "faces the in handle at (50, 50): {angle}"); + let (_, end) = sp.at_length(sp.length()); + assert!(end.is_finite(), "{end}"); + } + #[test] fn easing_keeps_the_ends_and_stays_monotone() { use crate::live::{BlendEase, BlendEaseType as K}; diff --git a/crates/doc/src/canvas_range.rs b/crates/doc/src/canvas_range.rs index cf85507c7..114235696 100644 --- a/crates/doc/src/canvas_range.rs +++ b/crates/doc/src/canvas_range.rs @@ -313,8 +313,8 @@ mod tests { d.guides = vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d.layers[0].id), ..Guide::new(true, 5.0) }]; d.saved_selections = vec![crate::records::SavedSelection { name: "s".into(), objects: vec![NodeId(10), NodeId(11)] }].into(); d.assets = vec![ - crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)] }, - crate::assets::ExportAsset { id: 2, name: "both".into(), nodes: vec![NodeId(10), NodeId(11)] }, + crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)], format_3d: None }, + crate::assets::ExportAsset { id: 2, name: "both".into(), nodes: vec![NodeId(10), NodeId(11)], format_3d: None }, ]; d.variables.bindings.insert(NodeId(11), ["v".to_string()].into()); d.variables.bindings.insert(NodeId(10), ["w".to_string()].into()); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 78f90dac6..ebd27fd87 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -34,6 +34,7 @@ impl Document { graphic_styles, char_styles, para_styles, + composite_fonts, text_threads, symbols, graph_designs, @@ -68,6 +69,7 @@ impl Document { print_setup, assets, svg_scripts, + svg_filters, puppet, new_art, dimensions, @@ -89,6 +91,7 @@ impl Document { && *graphic_styles == other.graphic_styles && *char_styles == other.char_styles && *para_styles == other.para_styles + && *composite_fonts == other.composite_fonts && *text_threads == other.text_threads && symbols.len() == other.symbols.len() && symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) @@ -135,6 +138,7 @@ impl Document { && *print_setup == other.print_setup && *assets == other.assets && *svg_scripts == other.svg_scripts + && *svg_filters == other.svg_filters && match (puppet, &other.puppet) { (Some(a), Some(b)) => Arc::ptr_eq(a, b), (a, b) => a.is_none() && b.is_none(), @@ -211,6 +215,7 @@ impl Document { graphic_styles: default_graphic_styles().into(), char_styles: Default::default(), para_styles: Default::default(), + composite_fonts: Default::default(), text_threads: vec![], symbols: vec![], graph_designs: vec![], @@ -245,6 +250,7 @@ impl Document { print_setup: None, assets: vec![], svg_scripts: vec![], + svg_filters: vec![], puppet: None, new_art: Default::default(), dimensions: false, diff --git a/crates/doc/src/document_ext.rs b/crates/doc/src/document_ext.rs index efd7f80b2..c40dcf9f2 100644 --- a/crates/doc/src/document_ext.rs +++ b/crates/doc/src/document_ext.rs @@ -123,6 +123,39 @@ impl Document { std::borrow::Cow::Owned(d) } + /// Does `wants` hold for a node anywhere in the document's art: the layers, symbol definitions, + /// graph designs, pattern swatches (the one being edited included) and their opacity masks? + pub fn any_node(&self, wants: impl Fn(&Node) -> bool) -> bool { + let roots = self + .layers + .iter() + .chain(self.symbols.iter().map(|s| &s.art)) + .chain(self.graph_designs.iter().map(|g| &g.art)) + .chain(self.patterns.iter().flat_map(|p| p.art.iter())) + .chain(self.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()).flat_map(|p| p.art.iter())); + let mut any = false; + for root in roots { + crate::swatches::walk_with_masks(root, &mut |n| any |= wants(n)); + } + any + } + + /// Change with `f` every node for which `wants` holds, anywhere in the document's art (as + /// [`Self::any_node`] looks). Only the trees that hold such a node are copied out of the art + /// they share. + pub fn edit_nodes_where(&mut self, wants: impl Fn(&Node) -> bool, mut f: impl FnMut(&mut Node)) { + let roots = self + .layers + .iter_mut() + .chain(self.symbols.iter_mut().map(|s| &mut s.art)) + .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) + .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); + for root in roots { + edit_branches_where(root, &wants, &mut f); + } + } + /// Round every path's anchors and handles to `decimals` decimal places: path objects (with /// Live Corners' uncut outlines), blend spines, envelopes' top-object outlines and Intertwine /// areas, in the layers, symbol definitions, graph designs, pattern swatches (the one being @@ -164,7 +197,7 @@ impl Document { } } NodeKind::Envelope { kind, .. } => { - if let EnvelopeKind::TopObject { path } = kind.as_mut() { + if let EnvelopeKind::TopObject { path, .. } = kind.as_mut() { round_path(path); } } @@ -194,6 +227,26 @@ impl Document { } } +/// Change with `f` the nodes of `n`'s tree (opacity-mask art included) for which `wants` holds, +/// copying out of the shared art only the branches that hold one. +fn edit_branches_where(n: &mut Arc, wants: &impl Fn(&Node) -> bool, f: &mut impl FnMut(&mut Node)) { + let mut any = false; + crate::swatches::walk_with_masks(n, &mut |c| any |= wants(c)); + if !any { + return; + } + let node = Arc::make_mut(n); + if wants(node) { + f(node); + } + if let Some(m) = node.extras_if_any().and_then(|e| e.mask.as_mut()) { + edit_branches_where(&mut m.art, wants, f); + } + for c in node.children_mut().into_iter().flatten() { + edit_branches_where(c, wants, f); + } +} + /// Round with `round` the anchors of the paths in `v`, nodes in their file form kept as JSON: every /// `{"subpaths": [{"anchors": [{"p", "in", "out"}]}]}` outside type (`{"type": "text"}`), as /// [`Document::round_geometry`] rounds the nodes it holds. diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index b19adc8b0..15f04ce15 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -321,6 +321,20 @@ pub struct SeriesPaint { /// Opacity (0..1) of the topmost stroke of each path directly in the series group, as [`Self::fill_opacity`]. #[serde(skip_serializing_if = "Option::is_none")] pub stroke_opacity: Option, + /// Blend mode of the topmost fill of each path directly in the series group, as [`Self::fill_opacity`]. Absent: Normal. + #[serde(skip_serializing_if = "Option::is_none")] + pub fill_blend: Option, + /// Blend mode of the topmost stroke of each path directly in the series group, as [`Self::fill_opacity`]. Absent: + /// Normal. + #[serde(skip_serializing_if = "Option::is_none")] + pub stroke_blend: Option, + /// Object opacity (0..1) of each path directly in the series group, taken from the object opacity set on one of them. + /// Absent: fully opaque. + #[serde(skip_serializing_if = "Option::is_none")] + pub opacity: Option, + /// Object blend mode of each path directly in the series group, as [`Self::opacity`]. Absent: Normal. + #[serde(skip_serializing_if = "Option::is_none")] + pub blend: Option, } /// Graph data and options. `rows` are categories (one per row of the Graph Data window), each diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index b3b4394a2..14bda9a00 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -84,8 +84,9 @@ impl Default for HitOptions { /// The shapes that an image's own fills and strokes paint, each with its fill rule and the fill or /// stroke that paints it (none for fills and strokes that paint nothing). Effects are evaluated -/// above this crate, so the engine registers one with [`set_image_paint`]. -pub type ImagePaint = fn(&Node) -> Vec<(BezPath, FillRule, AppearanceItem)>; +/// above this crate, so the engine registers one with [`set_image_paint`]. It looks up swatches and +/// patterns in the document it is given. +pub type ImagePaint = fn(Option<&Document>, &Node) -> Vec<(BezPath, FillRule, AppearanceItem)>; static IMAGE_PAINT: OnceLock = OnceLock::new(); @@ -98,8 +99,8 @@ pub fn set_image_paint(f: ImagePaint) { /// The shapes that the image `n`'s own fills and strokes paint ([`set_image_paint`]), none while /// nothing is registered. -pub(crate) fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> { - IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(n)) +pub(crate) fn image_paint(doc: Option<&Document>, n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> { + IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(doc, n)) } /// One level of evaluation of a live object in a document, as the canvas draws it: type in an @@ -127,11 +128,14 @@ fn live_inputs() -> u64 { } /// A live object evaluated by [`live_art`] on this thread: the object, its document's symbols (by -/// name and art) and patterns and the [`LiveInputs`] as they were, the art and its [`art_size`]. +/// name and art), patterns, swatches and color groups and the [`LiveInputs`] as they were, the art +/// and its [`art_size`]. struct EvaluatedLive { node: Node, symbols: Vec<(String, Arc)>, patterns: Vec, + swatches: crate::Shared>, + swatch_groups: crate::Shared>, inputs: u64, art: Rc>, size: usize, @@ -145,6 +149,8 @@ impl EvaluatedLive { && self.symbols.iter().zip(&doc.symbols).all(|((name, art), s)| *name == s.name && Arc::ptr_eq(art, &s.art)) && self.node == *n && self.patterns == doc.patterns + && self.swatches == doc.swatches + && self.swatch_groups == doc.swatch_groups } } @@ -183,8 +189,8 @@ thread_local! { /// The art live object `n` of `doc` draws ([`set_live_art`]); [`crate::live::expand_live`] while /// nothing is registered. The art is evaluated again when `n`, a symbol of `doc`, a pattern of -/// `doc` or the [`LiveInputs`] have changed since, and on every click while it or the document's -/// symbols or patterns hold a placed document ([`holds_placed`]). +/// `doc`, its swatches or color groups, or the [`LiveInputs`] have changed since, and on every +/// click while it or the document's symbols or patterns hold a placed document ([`holds_placed`]). fn live_art(doc: &Document, n: &Node) -> Rc> { let inputs = live_inputs(); let kept = EVALUATED.with_borrow_mut(|kept| { @@ -205,7 +211,16 @@ fn live_art(doc: &Document, n: &Node) -> Rc> { return art; } let symbols = doc.symbols.iter().map(|s| (s.name.clone(), s.art.clone())).collect(); - let e = EvaluatedLive { node: n.clone(), symbols, patterns: doc.patterns.clone(), inputs, art: art.clone(), size }; + let e = EvaluatedLive { + node: n.clone(), + symbols, + patterns: doc.patterns.clone(), + swatches: doc.swatches.clone(), + swatch_groups: doc.swatch_groups.clone(), + inputs, + art: art.clone(), + size, + }; EVALUATED.with_borrow_mut(|kept| { kept.insert(0, e); kept.truncate(LIVE_KEPT); @@ -437,7 +452,7 @@ pub fn locked_objects_at(doc: &Document, p: Point, opt: HitOptions) -> Vec) { let Some(children) = parent.children() else { return }; - if parent.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) { + if parent.clips() && !edits_inside(parent, opt.scope) && children.first().is_some_and(|clip| !clip_contains(clip, p)) { return; } for i in hit_order(parent, children, p) { @@ -653,6 +668,15 @@ fn clip_outline_hit(parent: &Node, clip: &Node, p: Point, opt: HitOptions, symbo clip_outline(clip, p, opt) } +/// Does isolation mode (`scope`) edit what `parent` holds: is `parent` the isolated container, or +/// the parent of the single object isolated? Then the selection tools hit the art that the +/// clipping mask of `parent` hides, and the Unlock submenu lists it (#3748). The reference +/// app's help says that the parts outside a clipping mask can still be edited +/// (). +fn edits_inside(parent: &Node, scope: Option) -> bool { + scope.is_some_and(|s| s == parent.id || parent.children().is_some_and(|c| c.iter().any(|c| c.id == s))) +} + fn hit_children( parent: &Node, p: Point, @@ -670,8 +694,10 @@ fn hit_children( chain.push(clip.id); return Some(Hit { leaf: clip.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }); } - // Elsewhere a clip group (or a layer with a clipping mask) only hits inside it. - if !clip_contains(clip, p) { + // Elsewhere a clip group (or a layer with a clipping mask) only hits inside it, except for + // the selection tools in isolation mode within it. + let edited = opt.clip_outlines && editable_only && edits_inside(parent, opt.scope); + if !edited && !clip_contains(clip, p) { return None; } } @@ -860,7 +886,7 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio if opt.outline { return None; } - image_paint(n).iter().rev().find_map(|(bp, rule, item)| match item { + image_paint(Some(symbols.document()), n).iter().rev().find_map(|(bp, rule, item)| match item { AppearanceItem::Stroke(s) => s.hits(bp, *rule, p, opt.tol).then_some(HitKind::Stroke), AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill), }) @@ -936,6 +962,257 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option } } +/// The art of a symbol instance with its own appearance changes, in the symbol's coordinates, and +/// how the canvas places each object of it: through the symbol's 9-slice guides when the instance +/// is scaled by them ([`Document::place_symbol_art`]), otherwise by the instance's transform. +struct InstanceArt { + art: Node, + map: Option, + xf: vectorcraft_geom::Affine, +} + +impl InstanceArt { + /// The art of symbol instance `inst`. `None` for anything else, a missing symbol, or art that + /// placing through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of, + /// which [`Document::place_symbol_art`] does not place and the canvas does not draw. + fn of(doc: &Document, inst: &Node) -> Option { + let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; + let sym = doc.symbols.iter().find(|s| s.name == *symbol)?; + let art = crate::symbol_overrides::dynamic_art(&sym.art, inst); + let map = doc.symbol_slice_map(symbol, *xf); + if map.as_ref().is_some_and(|m| m.most_placed(&art, doc) > crate::MAX_SYMBOL_NODES) { + return None; + } + Some(Self { art, map, xf: *xf }) + } + + /// The map that the objects inside group or layer `n` are moved by as one piece: `whole`, the + /// map of an object around `n` that is moved as one piece, or else the map of the 9-slice cell + /// `n` lies in ([`crate::NineSliceMap::one_cell`]). `None` when `n` is placed through the guides + /// object by object. + fn whole(&self, n: &Node, doc: &Document, whole: Option) -> Option { + whole.or_else(|| self.map.as_ref()?.one_cell(n, doc)) + } + + /// Object `n` of the art placed on the canvas as it is drawn, clip groups included: moved by + /// `whole` when an object around it is moved as one piece ([`Self::whole`]). `None` when + /// placing it through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects, as + /// [`Document::place_symbol_art`] refuses it. + fn place(&self, n: &Node, doc: &Document, whole: Option) -> Option { + match (&self.map, whole) { + (_, Some(a)) => Some(moved(n, a)), + (Some(m), None) => (m.most_placed(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place(n, doc)), + (None, None) => Some(moved(n, self.xf)), + } + } + + /// Object `n` of the art placed on the canvas without clip groups + /// ([`crate::NineSliceMap::place_unclipped`]), as [`Self::place`] places it and within the + /// same limit ([`Document::place_symbol_art_unclipped`]). + fn place_unclipped(&self, n: &Node, doc: &Document, whole: Option) -> Option { + match (&self.map, whole) { + (_, Some(a)) => Some(moved(n, a)), + (Some(m), None) => (m.most_placed_unclipped(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place_unclipped(n, doc)), + (None, None) => Some(moved(n, self.xf)), + } + } +} + +/// `n` transformed by `xf`, its strokes at their own weight. +fn moved(n: &Node, xf: vectorcraft_geom::Affine) -> Node { + let mut out = n.clone(); + out.transform(xf, false); + out +} + +/// The object of symbol instance `inst`'s art under `p`, for Direct Selection on a dynamic symbol's +/// instance: its path of child indices from the art's root ([`crate::symbol_overrides::part`]), the +/// topmost object hit inside the art's groups. With [`HitOptions::clip_outlines`], a press on the +/// outline of the clipping path of a clip group or a clipping layer picks that path before the art +/// it clips, as [`hit_test`] does outside symbols. Hidden, locked and template objects are passed +/// over, as [`hit_test`] passes over them when it looks for editable objects, and so are a Shaper +/// group's original shapes, which the canvas does not draw, and guide paths with +/// [`HitOptions::skip_guides`]. Nothing is picked in art that placing through the symbol's 9-slice +/// guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of, which the canvas does not +/// draw. `None` when `inst` is not a symbol instance or no editable object of its art is under `p`. +pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> Option> { + struct Find<'a> { + doc: &'a Document, + art: &'a InstanceArt, + p: Point, + opt: HitOptions, + symbols: &'a SymbolBoxes<'a>, + } + impl Find<'_> { + fn hit(&self, n: &Node, whole: Option) -> bool { + self.art.place(n, self.doc, whole).is_some_and(|n| hit_any(&n, self.p, self.opt, self.symbols).is_some()) + } + fn skips_guide(&self, n: &Node) -> bool { + self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. }) + } + fn find(&self, n: &Node, path: &mut Vec, whole: Option) -> bool { + if !n.visible || n.locked || n.is_template() || self.skips_guide(n) { + return false; + } + let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole) }; + if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH { + return self.hit(n, whole); + } + let whole = self.art.whole(n, self.doc, whole); + if n.clips() + && let Some(first) = children.first() + { + let Some(clip) = self.art.place_unclipped(first, self.doc, whole) else { return false }; + if self.opt.clip_outlines + && first.visible + && !first.locked + && !self.skips_guide(first) + && clip_outline(&clip, self.p, self.opt).is_some() + { + path.push(0); + return true; + } + if !clip_contains(&clip, self.p) { + return false; + } + } + for (i, c) in children.iter().enumerate().rev() { + // A Shaper group's original shapes are not drawn; symbol art has no isolation scope. + if shaper_hides(n, i, None) { + continue; + } + let Ok(i) = u32::try_from(i) else { continue }; + path.push(i); + if self.find(c, path, whole) { + return true; + } + path.pop(); + } + false + } + } + let art = InstanceArt::of(doc, inst)?; + let symbols = doc.symbol_boxes(); + let mut path = vec![]; + Find { doc, art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None).then_some(path) +} + +/// The outlines of the object at `path` in symbol instance `inst`'s art (see [`symbol_part_at`]), +/// placed on the canvas as it is drawn: its paths' outlines, and the bounds of objects without a +/// path. Inside a clip group (an object drawn once in each 9-slice cell it reaches is one clip +/// group per cell) they are cut to the bounds of the group's clip path, which is not outlined. An +/// object inside a clip group or a clipping layer of the art, other than its clipping path, is cut +/// to the bounds of that clipping path as well, which contain the region where [`symbol_part_at`] +/// picks it. +/// `None` for art past the 9-slice object limit that [`symbol_part_at`] applies. +pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option { + let art = InstanceArt::of(doc, inst)?; + let part = crate::symbol_overrides::part(&art.art, path)?; + let mut out = BezPath::new(); + let mut limit = None; + let mut whole = None; + let mut n = &art.art; + for &i in path { + let children = n.children()?; + whole = art.whole(n, doc, whole); + if i != 0 + && n.clips() + && let Some(first) = children.first() + && let Some(clip) = art.place_unclipped(first, doc, whole)?.geometric_bounds() + { + match meet(Some(clip), limit) { + Some(r) => limit = Some(r), + None => return Some(out), + } + } + n = children.get(usize::try_from(i).ok()?)?; + } + outline_within(&art.place(part, doc, whole)?, limit, &mut out); + Some(out) +} + +/// [`symbol_part_outline`] for placed object `n`, cut to `limit` when there is one. +fn outline_within(n: &Node, limit: Option, out: &mut BezPath) { + use vectorcraft_geom::kurbo::Shape; + if let Some(pd) = n.path_data() { + match limit { + Some(r) => push_cut(out, &pd.to_bezpath(), r), + None => out.extend(pd.to_bezpath()), + } + } else if n.children().is_none() + && let Some(b) = n.geometric_bounds() + && let Some(b) = meet(Some(b), limit) + { + out.extend(b.to_path(0.1)); + } + let Some(children) = n.children() else { return }; + if n.clips() + && let Some((first, rest)) = children.split_first() + { + let inner = match first.geometric_bounds() { + Some(c) => match meet(Some(c), limit) { + Some(r) => Some(r), + None => return, + }, + None => limit, + }; + rest.iter().for_each(|c| outline_within(c, inner, out)); + return; + } + children.iter().for_each(|c| outline_within(c, limit, out)); +} + +/// The overlap of `a` and `b` (`a` alone without `b`), `None` when they do not overlap. +fn meet(a: Option, b: Option) -> Option { + let a = a?; + let Some(b) = b else { return Some(a) }; + let r = Rect::new(a.x0.max(b.x0), a.y0.max(b.y0), a.x1.min(b.x1), a.y1.min(b.y1)); + (r.x0 <= r.x1 && r.y0 <= r.y1).then_some(r) +} + +/// The parts of path `p`, flattened to lines, that lie inside `r`, appended to `out`. +fn push_cut(out: &mut BezPath, p: &BezPath, r: Rect) { + use vectorcraft_geom::kurbo::PathEl; + let (mut at, mut start, mut last) = (Point::ZERO, Point::ZERO, None::); + vectorcraft_geom::kurbo::flatten(p.iter(), 0.1, |el| { + let to = match el { + PathEl::MoveTo(q) => { + (at, start, last) = (q, q, None); + return; + } + PathEl::LineTo(q) => q, + PathEl::ClosePath => start, + _ => return, + }; + if let Some((a, b)) = cut_segment(at, to, r) { + if last != Some(a) { + out.move_to(a); + } + out.line_to(b); + last = Some(b); + } + at = to; + }); +} + +/// The part of segment `a` to `b` inside `r`, `None` when none of it is. +fn cut_segment(a: Point, b: Point, r: Rect) -> Option<(Point, Point)> { + let d = b - a; + let (mut t0, mut t1) = (0.0_f64, 1.0_f64); + for (p, q) in [(-d.x, a.x - r.x0), (d.x, r.x1 - a.x), (-d.y, a.y - r.y0), (d.y, r.y1 - a.y)] { + if p.abs() < 1e-12 { + if q < 0.0 { + return None; + } + } else if p < 0.0 { + t0 = t0.max(q / p); + } else { + t1 = t1.min(q / p); + } + } + (t0 <= t1).then(|| (a + d * t0, a + d * t1)) +} + /// The path of compound path `n` under `p`, for the Group Selection tool: the one whose outline is /// within `tol`, else the innermost one whose area holds `p` (a hole inside an outer shape, an /// island inside a hole). `None` when `n` isn't a compound path or no part is under `p`. @@ -954,7 +1231,9 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option { /// out those on a locked artboard ([`crate::LockedArtboards::holds_id`]). Symbol instances are measured /// by their art ([`Node::geometric_bounds_with`]). The art of a clip group or of a layer with a /// clipping mask counts only when the marquee meets the region its clipping path clips to, and then -/// only within that region's bounds. +/// only within that region's bounds. In isolation mode the clipping mask of the isolated container, +/// or of the parent of the single object isolated, does not limit the marquee, as it does not limit +/// the selection tools' clicks. pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> Vec { marquee_with(doc, r, scope, leaves, false) } @@ -972,16 +1251,15 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, wh // Isolated art whose layer, or a group around it, was locked or hidden afterwards: the // marquee touches none of it, as clicks don't (#5309). Some(s) if !doc.is_editable(s.id) => vec![], - Some(s) => match (s.children(), marquee_in(s, r)) { - (Some(c), Some(r)) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(), - (Some(_), None) => vec![], + Some(s) => match s.children() { + Some(c) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(), // A single object isolated: only it. - (None, _) => doc + None => doc .parent_of(s.id) .and_then(|p| doc.node(p)) - .and_then(|p| Some((p.children()?, marquee_in(p, r)?))) + .and_then(|p| p.children()) .into_iter() - .flat_map(|(c, r)| c.iter().filter(|c| c.id == s.id).map(move |c| (c, r))) + .flat_map(|c| c.iter().filter(|c| c.id == s.id).map(|c| (c, r))) .collect(), }, // The objects of the layers, looking through sublayers (hidden, locked and template ones diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 9a5c40478..cb5b19d1a 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -255,7 +255,9 @@ fn a_selected_objects_hidden_art_on_a_preview_off_layer_counts_on_its_outline() /// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away /// half alone it selects nothing, and around a rectangle drawn beside the group's mask it /// takes that rectangle alone; reaching into the mask takes the group. The same holds for -/// Group Selection's leaves and in isolation mode. +/// Group Selection's leaves. In isolation mode (#3748) the isolated group's own mask does not +/// limit the marquee, as it does not limit clicks: with the group or the art isolated, the +/// marquee takes the hidden art. #[test] fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() { let mut d = Document::new(300.0, 200.0); @@ -270,8 +272,9 @@ fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() { let hidden = Rect::new(150.0, -10.0, 210.0, 50.0); assert!(marquee(&d, hidden, None, false).is_empty()); assert!(marquee(&d, hidden, None, true).is_empty()); - assert!(marquee(&d, hidden, Some(g), false).is_empty()); - assert!(marquee(&d, hidden, Some(art_id), false).is_empty()); + assert_eq!(marquee(&d, hidden, Some(g), false), vec![art_id]); + assert_eq!(marquee(&d, hidden, Some(g), true), vec![art_id]); + assert_eq!(marquee(&d, hidden, Some(art_id), false), vec![art_id]); // This marquee surrounds the rectangle beside and crosses the hidden art's bottom edge. assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside]); // This marquee crosses the shown half's top edge and goes on into the hidden half. @@ -602,11 +605,41 @@ fn evaluated_art_is_not_kept_while_a_placed_document_may_show_in_it() { fn a_kept_evaluation_is_stale_after_the_live_inputs_change() { let d = Document::new(100.0, 100.0); let n = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - let e = EvaluatedLive { node: n.clone(), symbols: vec![], patterns: vec![], inputs: 7, art: std::rc::Rc::new(vec![]), size: 0 }; + let e = kept_evaluation(&d, &n); assert!(e.is_of(&d, &n, 7)); assert!(!e.is_of(&d, &n, 8)); } +/// An evaluation of `n` in `d`, with inputs 7, holding no symbols, patterns or art. +fn kept_evaluation(d: &Document, n: &Node) -> EvaluatedLive { + EvaluatedLive { + node: n.clone(), + symbols: vec![], + patterns: vec![], + swatches: d.swatches.clone(), + swatch_groups: d.swatch_groups.clone(), + inputs: 7, + art: std::rc::Rc::new(vec![]), + size: 0, + } +} + +/// A kept evaluation is stale once a swatch changes, in a color group too: a painted live effect's +/// pieces name swatches. +#[test] +fn a_kept_evaluation_is_stale_after_a_swatch_changes() { + let mut d = Document::new(100.0, 100.0); + let n = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let e = kept_evaluation(&d, &n); + assert!(e.is_of(&d, &n, 7)); + d.swatches.first_mut().unwrap().name = "Renamed".into(); + assert!(!e.is_of(&d, &n, 7)); + let mut d = Document::new(100.0, 100.0); + let e = kept_evaluation(&d, &n); + d.swatch_groups.push(vectorcraft_color::SwatchGroup { name: "Added".into(), swatches: vec![] }); + assert!(!e.is_of(&d, &n, 7)); +} + /// A rectangle (40, 40)–(140, 140) clipping a filled 200 × 200 square drawn over its edges, in a /// clip group (or, `layer`, as the first layer's clipping mask): the group (or the layer), the /// clipping path and the art. @@ -682,6 +715,82 @@ fn select_behind_takes_a_layer_masks_outline_first() { assert_eq!(objects_at(&d, Point::new(138.5, 90.0), HitOptions::default(), None), vec![art, clip]); } +/// #3748: in isolation mode the selection tools hit the art that the isolated clip group (or +/// the isolated layer with a clipping mask) hides outside its mask, and the art when it is the +/// single object isolated; the mask's outline is still hit first. Outside isolation mode, and +/// for the tools that act on what is drawn, the hidden art is not hit. The Unlock submenu +/// lists the hidden art when it is locked. +#[test] +fn isolation_reaches_the_art_a_clipping_mask_hides() { + let hidden = Point::new(170.0, 90.0); + for layer in [false, true] { + let (mut d, g, clip, art) = masked_square(layer); + let isolated = HitOptions { scope: Some(g), ..SELECTING }; + assert!(hit_test(&d, hidden, SELECTING).is_none(), "layer {layer}: not isolated"); + let h = hit_test(&d, hidden, isolated); + assert_eq!(h.map(|h| (h.leaf, h.top_object(Some(g)))), Some((art, art)), "layer {layer}"); + assert_eq!(objects_at(&d, hidden, SELECTING, Some(g)), vec![art], "layer {layer}"); + assert_eq!(hit_test(&d, Point::new(140.0, 90.0), isolated).map(|h| h.leaf), Some(clip), "layer {layer}: the outline first"); + let one = HitOptions { scope: Some(art), ..SELECTING }; + assert_eq!(hit_test(&d, hidden, one).map(|h| h.leaf), Some(art), "layer {layer}: the art isolated"); + assert!(hit_test(&d, hidden, HitOptions { scope: Some(g), ..HitOptions::default() }).is_none(), "layer {layer}: drawn art only"); + assert!(hit_test_visible(&d, hidden, isolated).is_none(), "layer {layer}: sampling"); + d.node_mut(art).unwrap().locked = true; + assert_eq!(locked_objects_at(&d, hidden, HitOptions { scope: Some(g), ..HitOptions::default() }), vec![art], "layer {layer}"); + assert!(locked_objects_at(&d, hidden, HitOptions::default()).is_empty(), "layer {layer}: not isolated"); + } +} + +/// #3748, nested clip groups: with the outer clip group isolated, a click or a marquee takes +/// the art its own mask hides, while the mask of a clip group inside it still limits that +/// group's art. With the inner clip group isolated, or its art, the inner mask does not. +#[test] +fn isolation_reaches_hidden_art_only_for_the_isolated_clip_group() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let (outer, outer_clip, outer_art, inner, inner_clip, inner_art) = + (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id()); + let mask = |id, r| { + let mut path = Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0)); + if let NodeKind::Path { clipping, .. } = &mut path.kind { + *clipping = true; + } + Arc::new(path) + }; + let art = |id, r| Arc::new(Node::path(id, shapes::rectangle(r), Appearance::default_art())); + let inner_group = Node::new( + inner, + NodeKind::Group { + children: vec![mask(inner_clip, Rect::new(60.0, 60.0, 120.0, 120.0)), art(inner_art, Rect::new(40.0, 40.0, 160.0, 160.0))], + clip: true, + }, + ); + let children = + vec![mask(outer_clip, Rect::new(20.0, 20.0, 180.0, 180.0)), art(outer_art, Rect::new(190.0, 20.0, 260.0, 180.0)), Arc::new(inner_group)]; + d.insert(Some(l), 0, Node::new(outer, NodeKind::Group { children, clip: true })).unwrap(); + // `outer_hidden` is outside the outer mask, on the outer group's own art; `inner_hidden` is + // inside the outer mask and outside the inner one, on the inner group's art. + let (outer_hidden, inner_hidden, shown) = (Point::new(220.0, 100.0), Point::new(150.0, 100.0), Point::new(90.0, 90.0)); + let leaf = |p, scope| hit_test(&d, p, HitOptions { scope, ..SELECTING }).map(|h| h.leaf); + assert_eq!((leaf(outer_hidden, None), leaf(inner_hidden, None)), (None, None), "not isolated"); + assert_eq!(leaf(outer_hidden, Some(outer)), Some(outer_art), "outer isolated: its own hidden art"); + assert_eq!(leaf(inner_hidden, Some(outer)), None, "outer isolated: the inner mask still clips"); + let h = hit_test(&d, shown, HitOptions { scope: Some(outer), ..SELECTING }); + assert_eq!(h.map(|h| (h.leaf, h.top_object(Some(outer)))), Some((inner_art, inner)), "outer isolated: shown art"); + assert_eq!(leaf(inner_hidden, Some(inner)), Some(inner_art), "inner isolated"); + assert_eq!(leaf(outer_hidden, Some(inner)), None, "inner isolated: the outer group's art"); + assert_eq!(leaf(inner_hidden, Some(inner_art)), Some(inner_art), "the inner art isolated"); + // These marquees cross the right edge of the outer art (outside the outer mask) and of the + // inner art (inside the outer mask, outside the inner one). + let (outer_edge, inner_edge) = (Rect::new(255.0, 95.0, 265.0, 105.0), Rect::new(155.0, 95.0, 165.0, 105.0)); + assert_eq!(marquee(&d, outer_edge, Some(outer), false), vec![outer_art]); + assert!(marquee(&d, inner_edge, Some(outer), false).is_empty()); + assert!(marquee(&d, inner_edge, Some(outer), true).is_empty()); + assert_eq!(marquee(&d, inner_edge, Some(inner), false), vec![inner_art]); + assert!(marquee(&d, inner_edge, None, false).is_empty()); + assert!(marquee(&d, outer_edge, None, false).is_empty()); +} + /// Art in a sublayer is an object of its own: a click or a marquee takes it, not the /// sublayer; hidden, locked and template sublayers block both. #[test] @@ -1373,3 +1482,128 @@ fn cull_box_holds_the_reach_of_paths_and_compound_paths() { assert!(holds(cull, reach), "{cull:?} does not hold {reach:?}"); assert_eq!(checked, 201); } + +/// In a dynamic symbol's art, a layer with a clipping mask clips its objects as a clip group does: +/// Direct Selection picks an object only where the mask shows it, and outlines it there. +#[test] +fn an_object_a_clipping_layer_in_symbol_art_hides_is_not_picked_there() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let (mask, art) = half_clipped(&mut d); + let mut layer = Node::layer(d.alloc_id(), "Masked", crate::LayerColor::Preset(2)); + if let NodeKind::Layer { children, .. } = &mut layer.kind { + *children = vec![Arc::new(mask), Arc::new(art)]; + } + layer.set_clips(true); + let root = Node::group(d.alloc_id(), vec![Arc::new(layer)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Clipped".into(), art: Arc::new(root) }); + let inst = d.alloc_id(); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Clipped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + let n = d.node(inst).unwrap(); + let opt = HitOptions::default(); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), Some(vec![0, 1]), "where the mask shows the art"); + assert_eq!(symbol_part_at(&d, n, Point::new(150.0, 50.0), opt), None, "where the mask hides it"); + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[0, 1]).unwrap()); + assert!((b.x1 - 100.0).abs() < 1e-6, "the outline is cut to the mask: {b:?}"); + // Its outline inside the group around the layer is cut too. + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[0]).unwrap()); + assert!((b.x1 - 100.0).abs() < 1e-6, "the layer's outline is cut to the mask: {b:?}"); +} + +/// In a dynamic symbol's art that placing through its 9-slice guides could make more than +/// [`crate::MAX_SYMBOL_NODES`] objects of, Direct Selection picks and outlines nothing, as the +/// canvas draws nothing of it. At the art's natural size the same art is picked. +#[test] +fn symbol_art_past_the_object_limit_picks_nothing() { + use vectorcraft_color::{Gradient, GradientPaint}; + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art())); + let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_800])); + let mut mask = crate::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_800]), true); + mask.linked = false; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + // Drawn in six cells, each with its own copy of the mask's 3,240,000 squares. + let mut big = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(gradient, Paint::None, 0.0)); + big.extras_mut().mask = Some(Box::new(mask)); + let plain = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 50.0, 100.0, 100.0)), Appearance::default_art()); + let root = Node::group(d.alloc_id(), vec![Arc::new(plain), Arc::new(big)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Big".into(), art: Arc::new(root) }); + d.set_symbol_nine_slice("Big", Some(crate::SymbolNineSlice::default())); + let scaled = d.alloc_id(); + let natural = d.alloc_id(); + let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5); + d.insert(Some(l), 0, Node::new(scaled, NodeKind::SymbolInstance { symbol: "Big".into(), xf })).unwrap(); + d.insert(Some(l), 0, Node::new(natural, NodeKind::SymbolInstance { symbol: "Big".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + assert!(d.place_symbol_art("Big", &d.symbols[0].art, xf).is_none(), "the art is past the limit"); + let opt = HitOptions::default(); + let n = d.node(scaled).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(100.0, 110.0), opt), None, "the plain square"); + assert_eq!(symbol_part_at(&d, n, Point::new(100.0, 40.0), opt), None, "the masked path"); + assert!(symbol_part_outline(&d, n, &[0]).is_none()); + assert!(symbol_part_outline(&d, n, &[1]).is_none()); + let n = d.node(natural).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 75.0), opt), Some(vec![0])); + assert!(symbol_part_outline(&d, n, &[0]).is_some()); +} + +/// A clip group that lies in one 9-slice cell is moved by that cell as one piece, everything in it +/// included, so an object in it is picked and outlined there even when placing that object through +/// the guides on its own would pass [`crate::MAX_SYMBOL_NODES`] objects. +#[test] +fn objects_of_a_clip_group_in_one_cell_are_placed_with_it() { + use vectorcraft_color::{Gradient, GradientPaint}; + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art())); + let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_800])); + let mut mask = crate::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_800]), true); + mask.linked = false; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut big = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gradient, Paint::None, 0.0)); + big.extras_mut().mask = Some(Box::new(mask)); + let clip = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art()); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(big.clone())]); + group.set_clips(true); + let frame = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + let root = Node::group(d.alloc_id(), vec![Arc::new(frame), Arc::new(group)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Corner".into(), art: Arc::new(root) }); + d.set_symbol_nine_slice("Corner", Some(crate::SymbolNineSlice::default())); + let inst = d.alloc_id(); + let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Corner".into(), xf })).unwrap(); + let m = d.symbol_slice_map("Corner", xf).unwrap(); + assert!(m.most_placed(&big, &d) > crate::MAX_SYMBOL_NODES, "the object alone is past the limit"); + assert!(d.place_symbol_art("Corner", &d.symbols[0].art, xf).is_some(), "the art is drawn"); + let n = d.node(inst).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(10.0, 10.0), HitOptions::default()), Some(vec![1, 1])); + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[1, 1]).unwrap()); + assert!((b.x1 - 20.0).abs() < 1e-6 && (b.y1 - 20.0).abs() < 1e-6, "cut to the clip in its corner: {b:?}"); +} + +/// In a dynamic symbol's art, Direct Selection does not pick a Shaper group's original shapes, +/// which the canvas does not draw: a click over an original shape whose face the group erased +/// picks nothing, and a click over the face it shows picks the face. +#[test] +fn a_shaper_groups_original_shapes_in_symbol_art_are_not_picked() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)); + let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); + let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art()); + let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]); + sources.name = Some(crate::shaper::SOURCES.into()); + sources.opacity = 0.0; + let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); + face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX)); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(sources), Arc::new(face)]); + group.extras_mut().shaper = Some(Box::default()); + let root = Node::group(d.alloc_id(), vec![Arc::new(group)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Shaped".into(), art: Arc::new(root) }); + let inst = d.alloc_id(); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Shaped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + let n = d.node(inst).unwrap(); + let opt = HitOptions::default(); + assert_eq!(symbol_part_at(&d, n, Point::new(130.0, 50.0), opt), None, "over the erased original shape"); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), Some(vec![0, 1]), "over the shown face"); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index bff8e40f3..5735d9b48 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -109,7 +109,7 @@ pub use placed_document::{PageBox, PlacedDocument}; pub use profiles::ColorProfiles; pub use puppet::{PuppetPin, PuppetPins}; pub use rastersettings::{RasterColorModel, RasterEffectsSettings}; -pub use selection::{AnchorRef, SegmentRef, Selection}; +pub use selection::{AnchorRef, SegmentRef, Selection, SymbolPart}; pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource}; pub use style_libs::StyleLibrary; @@ -117,10 +117,11 @@ pub use text::FontHeightReference; pub use text::GlyphChoice; 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 text::{CharClass, CompositeFont, CompositePart, CompositeParts}; pub use trace::TraceView; pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables}; pub use vectorcraft_color as color; @@ -141,7 +142,7 @@ mod limits; mod records; mod units; -pub use artboard::{Artboard, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS}; +pub use artboard::{ARTBOARD_GAP, Artboard, ArtboardSide, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS}; pub use canvas_range::BeyondCanvas; #[cfg(test)] use document_ext::free_id; @@ -150,7 +151,7 @@ pub use guides::{GridPrefs, Guide, RulerMode}; #[cfg(test)] use limits::MAX_NEXT_ID; pub use limits::{DocError, FORMAT_VERSION, MAX_COORD}; -pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, Symbol, SymbolEdit}; +pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, SvgFilter, Symbol, SymbolEdit}; pub use units::{ColorMode, Unit, parse_number}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -195,6 +196,9 @@ pub struct Document { /// Paragraph styles (besides the built-in [Normal Paragraph Style]). #[serde(default, skip_serializing_if = "skip::is_empty_vec")] pub para_styles: Shared>, + /// Type › Composite Fonts…: the composite fonts defined in the document, by name. + #[serde(default, skip_serializing_if = "skip::is_empty_vec")] + pub composite_fonts: Shared>, /// Threaded text: area-type frames one story flows through, in order. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub text_threads: Vec>, @@ -310,6 +314,10 @@ pub struct Document { /// given) SVG export links with `", "", + "", + "", + ] { + let e = Effect { id: SVG_FILTER.into(), params: json!({ "xml": xml }), visible: true }; + assert!(raster_effects(&[e]).is_empty(), "{xml}"); + } +} + +/// Scale Strokes & Effects scales the filter's lengths with the art: the points a unit measures. +#[test] +fn scaling_the_effect_scales_its_units() { + let mut e = Effect { id: SVG_FILTER.into(), params: json!({ "xml": RED_FLOOD }), visible: true }; + crate::scale_effect(&mut e, 2.0); + assert_eq!(e.params["scale"], json!(2.0)); + crate::scale_effect(&mut e, 0.25); + assert_eq!(e.params["scale"], json!(0.5)); +} + +/// The form SVG output writes, for a filter that fills a 10 × 10 point box. +fn written(xml: &str) -> Option { + filter(json!({ "xml": xml })).written_xml(Rect::new(0.0, 0.0, 10.0, 10.0)) +} + +/// SVG 1.1 output writes an image's reference as `xlink:href`; the filter as it is drawn and +/// listed keeps `href`. +#[test] +fn an_image_is_written_with_an_xlink_reference() { + let data = format!("data:image/png;base64,{}", base64(&png_claiming(2, 2))); + let f = filter(json!({ "xml": format!(r#""#) })); + assert_eq!(f.xml(), format!(r#""#)); + let out = f.written_xml(Rect::new(0.0, 0.0, 10.0, 10.0)).unwrap(); + assert_eq!(out, format!(r#""#)); + assert!(f.writes_as_svg11()); + assert_eq!(check(&out), Ok("i".into()), "the written form reads back"); +} + +/// SVG 1.1 has no `feDropShadow`: it is written as the primitives Filter Effects gives as its +/// equivalent, with its defaults, input, result and subregion, and results no other primitive uses. +#[test] +fn a_drop_shadow_is_written_as_svg_1_1_primitives() { + assert_eq!( + written(r#""#).as_deref(), + Some(concat!( + r#""#, + r#""#, + )) + ); + // Its input, result, subregion, color space and flood; a name the filter uses is not taken. + let given = r#""#; + assert_eq!( + written(given).as_deref(), + Some(concat!( + r#""#, + r#""#, + r##""##, + r#""#, + r#""#, + )) + ); + // An implicit input, or a result no earlier primitive has, is the previous result, named. + for shadow in [r#""#, r#""#] { + let out = written(&format!(r#"{shadow}"#)).unwrap(); + assert!(out.starts_with(r##""#), "{out}"); + assert_eq!(check(&out), Ok("s".into()), "the written form reads back"); + } + // What SVG 1.1 can't hold leaves the filter to be written as other pixel effects are. + for xml in [ + r#""#, + r#""#, + ] { + let f = filter(json!({ "xml": xml })); + assert!(!f.writes_as_svg11() && f.written_xml(Rect::new(0.0, 0.0, 10.0, 10.0)).is_none(), "{xml}"); + } + // An offset that isn't a number is the shadow's default, which an `feOffset` doesn't share. + let out = written(r#""#).unwrap(); + assert!(out.contains(r#"{}"#, "".repeat(13)); + assert!(!filter(json!({ "xml": many })).writes_as_svg11()); +} + +/// Each input is written as the primitive it reads when drawn: a result name only usvg gives (to +/// a primitive without a `result`, here the shadow), and the inputs usvg draws as `SourceGraphic`. +#[test] +fn inputs_are_written_as_the_primitives_they_read() { + let xml = r#""#; + let out = written(xml).unwrap(); + assert_eq!( + out, + concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ) + ); + assert_eq!(check(&out), Ok("s".into()), "the written form reads back"); +} + +/// Other values SVG 1.1 lacks: SVG 1.1 values that draw alike, or no SVG 1.1 form. +#[test] +fn values_svg_1_1_lacks_are_written_as_resvg_draws_them() { + // `lighter` (Filter Effects) is drawn as the default `over`, and a blur has no `edgeMode`. + assert_eq!( + written(r#""#) + .as_deref(), + Some(r#""#) + ); + assert_eq!( + written(r#""#).as_deref(), + Some(r#""#) + ); + // CSS colors as `#rrggbb`, a flood color's alpha in its opacity, a lighting color's dropped. + assert_eq!( + written(r#""#) + .as_deref(), + Some( + r##""## + ) + ); + // Image scaling as an SVG 1.1 keyword. + let data = format!("data:image/png;base64,{}", base64(&png_claiming(2, 2))); + for (given, kept) in + [("pixelated", "optimizeSpeed"), ("crisp-edges", "optimizeSpeed"), ("smooth", "optimizeQuality"), ("auto", "optimizeQuality")] + { + let out = written(&format!(r#""#)).unwrap(); + assert!(out.contains(&format!(r#"style="image-rendering:{kept}""#)), "{given}: {out}"); + } + // The blend modes SVG 1.1 lacks have no SVG 1.1 form. + for mode in LATER_BLEND_MODES { + let f = filter(json!({ "xml": format!(r#""#) })); + assert!(!f.writes_as_svg11(), "{mode}"); + } +} + +#[test] +fn base64_pads_as_rfc_4648_does() { + assert_eq!(base64(b""), ""); + assert_eq!(base64(b"f"), "Zg=="); + assert_eq!(base64(b"fo"), "Zm8="); + assert_eq!(base64(b"foo"), "Zm9v"); + assert_eq!(base64(b"foobar"), "Zm9vYmFy"); +} diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index ea3843f0e..532251bd7 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -180,6 +180,24 @@ fn warp_zero_bend_is_identity_ish() { } } +/// `warp::bends` says a warp moves placed gradients exactly when the warp moves the outline: +/// Arc leaves the outline as it is under a bend of 0.0001 % and moves it above that, and the other +/// styles move it at any bend. +#[test] +fn warp_bends_when_the_warp_moves_the_outline() { + // A star: Fisheye moves nothing on the edge of the box, where an ellipse or a rectangle lies. + let oval = shapes::star(Point::new(51.7, 34.3), 40.9, 17.3, 5, 7.0); + for (s, _) in WARP_STYLES { + let id = format!("warp.{s}"); + let flat = run(&id, json!({"bend": 0}), &oval); + for bend in [0.0, 1e-5, -1e-5, 0.9e-4, -0.9e-4, 1.1e-4, -1.1e-4] { + let p = json!({ "bend": bend }); + let moved = run(&id, p.clone(), &oval) != flat; + assert_eq!(crate::warp::bends(&id, &p), moved, "{id} at {bend} %"); + } + } +} + #[test] fn warp_arc_bends_the_top_edge() { let out = run("warp.arc", json!({"bend": 50}), &square()); @@ -394,7 +412,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() { @@ -407,6 +425,140 @@ fn stacked_effects_on_art_stay_within_the_anchor_budget() { assert_eq!(reshaped, 10); } +/// A path that keeps its shape because its Transform effect would pass the budget keeps the +/// gradients that paint the art as a whole in place too, also with Scale Strokes & Effects on. +#[test] +fn art_past_the_budget_keeps_its_own_gradients_in_place() { + use std::sync::Arc; + + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + use vectorcraft_doc::{AppearanceItem, FillLayer, NodeId, color::Paint}; + let id = NodeId(9); + let stops = vec![GradientStop::new(0.0, vectorcraft_color::Color::BLACK), GradientStop::new(1.0, vectorcraft_color::Color::WHITE)]; + let geom = GradientGeom { start: (0.0, 50.0).into(), end: (100.0, 50.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); + let circle = || Arc::new(Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default())); + // The group's own fill, a gradient placed across the circle, after `carry` moves it. + let own_end = |carry: &crate::Carry| { + let mut ap = Appearance::default(); + ap.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone()))); + carry.apply(&mut ap); + match ap.fill_paint() { + Paint::Gradient(g) => g.geom.map(|g| g.end.x), + _ => None, + } + }; + for params in [json!({"scaleH": 1000, "scaleV": 1000}), json!({"scaleH": 1000, "scaleV": 1000, "strokes": true})] { + let grow = fx("distort.transform", params.clone()); + let mut art = Node::group(id, vec![circle()]); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget::EFFECTS, None); + let end = own_end(&carry).unwrap(); + assert!((end - 550.0).abs() < 1e-6, "{params}: within the budget the group's gradient grows with the circle: {end}"); + // No room beyond what the circle costs: scaled up, it would be drawn with more lines. + let mut art = Node::group(id, vec![circle()]); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 }, None); + let b = art.geometric_bounds().unwrap(); + assert!(b.width() < 101.0 && b.height() < 101.0, "{params}: the circle keeps its shape: {b:?}"); + assert_eq!(own_end(&carry), Some(100.0), "{params}: the group's gradient stays where it was placed"); + // A group inside the art whose square took the effect moves its own gradient all the same. + let square = Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default())); + let mut inner = Node::group(NodeId(2), vec![square]); + inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone()))); + let mut art = Node::group(id, vec![circle(), Arc::new(inner)]); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 }, None); + assert_eq!(own_end(&carry), Some(100.0), "{params}: the art's own gradient stays"); + let inner = &art.children().unwrap()[1]; + let Paint::Gradient(g) = inner.appearance.fill_paint() else { panic!("a gradient") }; + assert!((g.geom.unwrap().end.x - 550.0).abs() < 1e-6, "{params}: the inner group's gradient: {:?}", g.geom); + } +} + +/// A hidden path in a layer that keeps its shape past the budget does not hold the layer's +/// gradients in place: the layer's bounds leave it out, so the layer's own gradient moves with +/// the visible art under a Transform effect and Free Distort, as does the art's when the layer is +/// the art. +#[test] +fn a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move() { + use std::sync::Arc; + + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + use vectorcraft_doc::{AppearanceItem, FillLayer, LayerColor, NodeId, NodeKind, color::Paint}; + let stops = vec![GradientStop::new(0.0, vectorcraft_color::Color::BLACK), GradientStop::new(1.0, vectorcraft_color::Color::WHITE)]; + let geom = GradientGeom { start: (0.0, 50.0).into(), end: (100.0, 50.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); + let end = |ap: &Appearance| match ap.fill_paint() { + Paint::Gradient(g) => g.geom.map(|g| g.end.x), + _ => None, + }; + // A layer with a visible square and a hidden circle, both 0..100, and a gradient fill of its own. + let layer = || { + let square = Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default())); + let mut circle = Node::path(NodeId(4), shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default()); + circle.visible = false; + let mut layer = Node::layer(NodeId(2), "Layer 1", LayerColor::Preset(0)); + if let NodeKind::Layer { children, .. } = &mut layer.kind { + *children = vec![square, Arc::new(circle)]; + } + layer.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone()))); + layer + }; + // No room beyond what the paths cost: the square takes each effect, the circle would be drawn + // with more lines and keeps its shape. + let budget = Budget { anchors: usize::MAX, lines: 0 }; + // Each scales the art ten times about its center: 0..100 becomes -450..550. + let corners = json!({"corners": [[-4.5, -4.5], [5.5, -4.5], [5.5, 5.5], [-4.5, 5.5]]}); + for grow in [fx("distort.transform", json!({"scaleH": 1000, "scaleV": 1000})), fx("distort.freeDistort", corners)] { + let mut art = layer(); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget, None); + let circle = art.children().unwrap()[1].geometric_bounds().unwrap(); + assert!(circle.width() < 101.0, "{}: the hidden circle keeps its shape: {circle:?}", grow.id); + let square = art.children().unwrap()[0].geometric_bounds().unwrap(); + assert!((square.x1 - 550.0).abs() < 1e-6, "{}: the square takes the effect: {square:?}", grow.id); + let mut own = Appearance::clone(&art.appearance); + carry.apply(&mut own); + assert_eq!(end(&own), Some(550.0), "{}: the layer's gradient, as the art", grow.id); + let mut art = Node::group(NodeId(9), vec![Arc::new(layer())]); + crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget, None); + assert_eq!(end(&art.children().unwrap()[0].appearance), Some(550.0), "{}: the layer's gradient, inside the art", grow.id); + } +} + +/// A freeform gradient whose points were edited (its vector still fitted to the bounds) moves its +/// points with a Transform effect and Free Distort, on a path, on a group inside the art and on the +/// art itself, as a placed gradient does. +#[test] +fn edited_freeform_points_follow_a_transform_effect_and_free_distort() { + use std::sync::Arc; + + use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint}; + use vectorcraft_doc::{AppearanceItem, FillLayer, NodeId, color::Paint}; + let mut ramp = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Gradient::default() }); + let pt = |x: f64, c| FreeformPoint::new(Point::new(x, 50.0), c); + ramp.set_freeform(Freeform { points: vec![pt(0.0, Color::BLACK), pt(100.0, Color::WHITE)], ..Default::default() }); + assert!(ramp.geom.is_none() && !ramp.is_placed()); + let freeform = Paint::Gradient(Box::new(ramp)); + let xs = |ap: &Appearance| match ap.fill_paint() { + Paint::Gradient(g) => g.freeform.as_ref().map(|f| f.points.iter().map(|p| p.at.x).collect::>()), + _ => None, + }; + let square = |ap: Appearance| Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap)); + // Each moves the art 30 pt right. + let corners = json!({"corners": [[0.3, 0.0], [1.3, 0.0], [1.3, 1.0], [0.3, 1.0]]}); + for e in [fx("distort.transform", json!({"moveH": 30})), fx("distort.freeDistort", corners)] { + let mut inner = Node::group(NodeId(2), vec![square(Appearance::default())]); + inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(freeform.clone()))); + let mut art = Node::group(NodeId(9), vec![square(Appearance::basic(freeform.clone(), Paint::None, 0.0)), Arc::new(inner)]); + art.appearance.items.push(AppearanceItem::Fill(FillLayer::new(freeform.clone()))); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&e], Budget::EFFECTS, None); + let members = art.children().unwrap(); + assert_eq!(xs(&members[0].appearance), Some(vec![30.0, 130.0]), "{}: the path's points", e.id); + assert_eq!(xs(&members[1].appearance), Some(vec![30.0, 130.0]), "{}: the inner group's points", e.id); + let mut own = Appearance::clone(&art.appearance); + carry.apply(&mut own); + assert_eq!(xs(&own), Some(vec![30.0, 130.0]), "{}: the art's own points", e.id); + } +} + /// #3578: a large Roughen draws each of its points as many lines; they stay within the budget, /// the Roughen built again with fewer points where they wouldn't. #[test] @@ -473,7 +625,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 +652,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 +660,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 +668,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/effects/src/warp.rs b/crates/effects/src/warp.rs index fb0279ede..84f196a8b 100644 --- a/crates/effects/src/warp.rs +++ b/crates/effects/src/warp.rs @@ -32,3 +32,14 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) - let piece = b.width().hypot(b.height()) / 24.0; map_nonlinear_within(path, piece, max, f) } + +/// Does warp effect `id` with params `p` move any point? Every style leaves the box as it is with +/// no bend and no distortion, and Arc (`warp.arc`) takes a bend under 0.0001 % as none: the +/// threshold must match the one in [`warp_point`]'s Arc case (`warp_bends_when_the_warp_moves_the_outline` +/// tests both sides of it). +pub(crate) fn bends(id: &str, p: &Value) -> bool { + let share = |key: &str, default: f64| num(p, key, default).clamp(-100.0, 100.0) / 100.0; + let bend = share("bend", 50.0); + let bent = if id == "warp.arc" { bend.abs() >= 1e-6 } else { bend != 0.0 }; + bent || share("horizontal", 0.0) != 0.0 || share("vertical", 0.0) != 0.0 +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index eddf966db..3394585bb 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -9,14 +9,19 @@ description = "VectorCraft engine: sessions, history, selection, the command reg workspace = true [features] -default = ["bundled-fonts"] +default = ["bundled-fonts", "heif"] # The fonts vectorcraft-text bundles (see its `bundled-fonts`). Embedders that supply every font at # run time (`vectorcraft_text::FontDb::add_font`) build with `default-features = false`. bundled-fonts = ["vectorcraft-text/bundled-fonts"] +# HEIC and HEIF images open and place, decoded by heic-rs (an HEVC decoder). A build without this +# feature still recognizes the files and refuses them with a message. The apps list it beside +# `bundled-fonts`. +heif = ["dep:heic-rs", "dep:rayon"] [dependencies] # Which paths stored in documents may be opened. craft-docpath = { workspace = true } +craft-proof = { workspace = true } vectorcraft-geom = { workspace = true } vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true } @@ -36,7 +41,12 @@ vectorcraft-plugins = { workspace = true } vectorcraft-affinity = { workspace = true } vectorcraft-scriptcheck = { workspace = true } image = { workspace = true } +jxl-oxide = { workspace = true } +jxl-color = { workspace = true } +jxl-grid = { workspace = true } hayro-jpeg2000 = { version = "=0.3.5", default-features = false, features = ["std", "simd"] } +# Pure Rust, no unsafe; pinned at the version photocraft-heif uses. +heic-rs = { version = "=0.1.4", default-features = false, features = ["std"], optional = true } flate2 = { workspace = true } chrono = { workspace = true } # The XML parts of Word documents placed as type (place/docx.rs). @@ -54,6 +64,11 @@ log = { workspace = true } [target.'cfg(unix)'.dependencies] rustix = { workspace = true, features = ["std", "process"] } +# The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +heic-rs = { version = "=0.1.4", default-features = false, features = ["std", "parallel"], optional = true } +rayon = { version = "1.12", optional = true } + [dev-dependencies] vectorcraft-affinity = { workspace = true, features = ["synth"] } crc32fast = { workspace = true } @@ -66,5 +81,8 @@ vectorcraft-pdf = { workspace = true, features = ["read-count"] } 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 } # The JavaScript parser's lexer, compared with the script checker's on the same scripts. vectorcraft-js = { workspace = true } diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index a26c75da2..625aa5232 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -1,5 +1,6 @@ //! The command registry. Ids follow Illustrator's menu structure. +pub mod actionset; mod addons; pub(crate) mod appearance; mod assets; @@ -11,6 +12,7 @@ pub mod clipboard; mod colorcmds; pub mod colormgmt; pub mod colortheme; +pub mod compositefonts; mod compoundshape; pub(crate) mod create; mod css; @@ -80,9 +82,11 @@ mod select; pub(crate) mod shaper; pub mod shortcutset; pub(crate) mod slices; +pub mod spelling; mod stroke; mod style; pub mod stylelib; +mod svgfilter; mod swatch; pub mod swatchlib; pub(crate) mod symboledit; @@ -284,6 +288,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(inline::specs()); v.extend(textstyles::specs()); v.extend(fonts::specs()); + v.extend(compositefonts::specs()); v.extend(fontfiles::specs()); v.extend(help::specs()); v.extend(threads::specs()); @@ -317,6 +322,8 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(expand::specs()); v.extend(attributes::specs()); v.extend(interactivity::specs()); + v.extend(spelling::specs()); + v.extend(svgfilter::specs()); v.extend(newart::specs()); v.extend(colortheme::specs()); v.extend(fileio::pdf::specs()); @@ -344,14 +351,23 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(halftone::specs()); v.extend(perspgrid::specs()); v.extend(shortcutset::specs()); + v.extend(actionset::specs()); v.extend(importmatches::specs()); v.extend(addons::specs()); v }) } +/// The command `id`: a built-in one, or a command an installed plug-in adds +/// ([`plugin::command_specs`]). pub fn find_command(id: &str) -> Option<&'static CommandSpec> { - command_specs().iter().find(|c| c.id == id) + command_specs().iter().find(|c| c.id == id).or_else(|| plugin::find_spec(id)) +} + +/// Runs command `spec`'s implementation with `p`. A plug-in command's runs the plug-in +/// ([`plugin::run_command`]), which needs the command's id. +pub(crate) fn run_spec(s: &mut Session, spec: &CommandSpec, p: &Value) -> Result { + if plugin::is_command_id(spec.id) { plugin::run_command(s, spec.id, p) } else { (spec.run)(s, p) } } // ---------- param helpers ---------- @@ -416,8 +432,22 @@ pub(crate) fn text_param<'a>(p: &'a Value, key: &str, cmd: &str) -> Result Option { p.get(key).and_then(Value::as_u64).map(NodeId) } -pub(crate) fn ids_param(p: &Value, key: &str) -> Option> { - p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect()) +/// The object ids `p` gives for `key`, or `None` when `p` leaves it out or gives null. A value that +/// is not an array, or an entry that is not a non-negative integer, is an error with the message +/// [`checked_ids_param`] gives, so no entry is dropped. Whether each id names an object is left to +/// the caller. +pub(crate) fn ids_param(p: &Value, key: &str, cmd: &str) -> Result>> { + let values = match p.get(key) { + None | Some(Value::Null) => return Ok(None), + Some(Value::Array(a)) => a, + Some(_) => return Err(bad(cmd, format!("{key} must be an array of non-negative integer object ids"))), + }; + values.iter().enumerate().map(|(index, value)| id_entry(value, key, index, cmd)).collect::>>().map(Some) +} +/// Entry `index` (from 0) of the `key` list as an object id: anything but a non-negative integer is +/// an error naming the key, the entry and its value. +fn id_entry(value: &Value, key: &str, index: usize, cmd: &str) -> Result { + value.as_u64().map(NodeId).ok_or_else(|| bad(cmd, format!("`{key}` entry {index} is {value}, not a non-negative integer object id"))) } /// The objects `p` gives `command`: its `ids`, else its `id` as `ids` of one (as [`targets`] reads /// them), each checked ([`checked_id`]): a malformed one, or one naming no object, is an error, @@ -443,7 +473,7 @@ pub(crate) fn index(v: u64) -> usize { pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result> { let values = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(command, format!("{key} must be an array of non-negative integer object ids")))?; - values.iter().map(|value| checked_id(s, value, command)).collect() + values.iter().enumerate().map(|(index, value)| id_entry(value, key, index, command).and_then(|_| checked_id(s, value, command))).collect() } /// The objects a command acts on: `ids` when given (every one checked, and those inside another @@ -590,11 +620,16 @@ pub(crate) fn object_param(p: &Value, key: &str, cmd: &str) -> Result Result> { match named_ids(p)? { Some(ids) => Ok(ids), - None => Ok(s.doc()?.selection.objects.clone()), + None => { + edit::refuse_symbol_part(s)?; + Ok(s.doc()?.selection.objects.clone()) + } } } @@ -667,6 +702,7 @@ pub(crate) const MAX_IMPORTED_PRESETS: usize = 1000; pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; +pub(crate) use object::{keep_aligned_in, refresh_pixel_aligned}; pub(crate) use vectorcraft_doc::range::parse_range; /// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see diff --git a/crates/engine/src/cmd/actionset.rs b/crates/engine/src/cmd/actionset.rs new file mode 100644 index 000000000..b6a911838 --- /dev/null +++ b/crates/engine/src/cmd/actionset.rs @@ -0,0 +1,560 @@ +//! Action sets another app saves as text (`.aia` files that start `/version`), read into their +//! actions and steps without changing anything (`actionSet.read`). +//! +//! The text is a set of fields and blocks. A slash name followed by a value sets a field, and a +//! slash name followed by `{` opens a block that `}` closes. A value is a decimal number, text in +//! parentheses, a byte count and hexadecimal bytes in square brackets (`[ 5 48656c6c6f ]`, UTF-8 +//! text, which can run over several words), or a byte count and hexadecimal bytes in angle brackets +//! (`< 3 0a0b0c >`, raw bytes). The set's `/version` comes first, and its `/name` is hexadecimal +//! text. Each action is an `/action-N` block with a hexadecimal `/name`; each step is an `/event-N` +//! block inside one, with its `/internalName` in parentheses, the `/localizedName` it is shown with +//! as hexadecimal text, and `/isOn` 0 when it is left out of playback; each value a step was +//! recorded with is a `/parameter-N` block inside it, which is counted, and whose hexadecimal +//! `/value` is checked against its byte count. The set's `/actionCount`, an action's `/eventCount` +//! and a step's `/parameterCount` give how many actions, steps and recorded values follow. +//! The layout follows the README of Inventsable's Redesigning-Illustrator-Actions-in-Scripting +//! (https://github.com/Inventsable/Redesigning-Illustrator-Actions-in-Scripting), the forum post +//! "Made a two-way aia to json converter" +//! (https://community.adobe.com/t5/illustrator-discussions/made-a-two-way-aia-to-json-converter/m-p/13751909), +//! sttk3's article on names as UTF-8 hexadecimal +//! (https://sttk3.com/blog/tips/illustrator/dynamic-generate-action.html), and the MIT-licensed +//! reader in zachshallbetter/adobe-mcp (https://github.com/zachshallbetter/adobe-mcp). The raw form +//! follows the forum thread "Creating a dynamic action" +//! (https://community.adobe.com/questions-652/creating-a-dynamic-action-to-use-with-app-doscript-method-814406). + +use serde_json::{Value, json}; + +use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, text_param}; + +/// The most bytes of a file read. +const MAX_BYTES: usize = 8 << 20; +/// The most bytes taken in: [`MAX_BYTES`] after a byte order mark, and one more that shows the text +/// goes on. +const READ_LIMIT: usize = MAX_BYTES + 4; +/// The most blocks open at once. +const MAX_DEPTH: usize = 16; +/// The most actions read. +const MAX_ACTIONS: usize = 1000; +/// The most steps read in one action. +const MAX_STEPS: usize = 10_000; +/// The most steps read in the set: a small text can hold hundreds of thousands of empty steps, and +/// the answer holds an object for each. +const MAX_SET_STEPS: usize = 100_000; +/// The longest name kept (bytes). +const MAX_NAME: usize = 1024; +/// The most tokens listed as unread. +const MAX_UNREAD: usize = 1000; +/// The longest unread token shown (characters). +const UNREAD_SHOWN: usize = 80; + +/// The extensions of action sets other apps save. +pub const EXTS: &[&str] = &["aia"]; + +pub fn specs() -> Vec { + vec![cmd!( + query "actionSet.read", + "Read Action Set File", + [], + None, + "{path? | data?: the file's text | dataBase64?, name?: the file's name (default: the path's)} read an action set another app saved as text (.aia, starting \"/version\") without changing anything → {set: the set's /name (else the file's name without its extension, else empty), version, actions: [{name, steps: [{name: the step's /internalName, label: its /localizedName, or null when it has none or it is blank, on: false when /isOn is 0, params: how many /parameter blocks it has, line}]}], unread: [{line, text}] (tokens that aren't read, at most 1000), truncated: true when a cap stopped the reading (8 MiB, 16 open blocks, 1,000 actions, 10,000 steps in an action, 100,000 steps in the set; names past 1,024 bytes are cut), when the text ends inside a block or a value or after a field's name, when /actionCount, /eventCount or a step's /parameterCount differs from the actions, steps or recorded values read (a negative count, one too large for a number and one that isn't a number always differ), or when a hexadecimal name, label or recorded /value isn't as many bytes as its count says}. Give exactly one of path, data and dataBase64. Text that doesn't start with /version and a number is an error", + always, + read + )] +} + +/// A step of an action: an `/event-N` block. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AiaStep { + /// Its `/internalName`. + pub name: String, + /// Its `/localizedName`, if it has one. + pub label: Option, + /// Its `/isOn`, if it has one. + pub on: Option, + /// How many `/parameter-N` blocks it has. + pub params: usize, + /// Its `/parameterCount`, if it has one. + pub declared: Option, + /// The line its block opens on (from 1). + pub line: usize, +} + +/// An action: an `/action-N` block. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AiaAction { + pub name: String, + pub steps: Vec, + /// Its `/eventCount`, if it has one. + pub declared: Option, +} + +/// An action set read from text. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AiaSet { + pub version: i64, + /// The set's `/name`, if it has one. + pub name: Option, + pub actions: Vec, + /// Its `/actionCount`, if it has one. + pub declared: Option, + /// The tokens that weren't read, with their lines. + pub unread: Vec<(usize, String)>, + /// A cap stopped the reading or cut a name, the text ends inside a block or a value or after a + /// field's name, its counts differ from the actions, steps and recorded values read (a negative + /// count, one too large for an `i64` and one that isn't a number always differ), or a hexadecimal + /// name, label or recorded `/value` isn't as many bytes as its count says. + pub truncated: bool, + /// How many steps were read in all the actions. + steps: usize, +} + +/// A token of the text. +enum Token { + /// A slash name. + Name(Vec), + /// An integer; `None` when it doesn't fit an `i64`. + Int(Option), + /// Text in parentheses, a backslash taking the next byte as it is. + Text(Vec), + /// The bytes of `[ count hex… ]`; `None` when the hexadecimal digits don't make whole bytes or + /// as many bytes as the count says. + Hex(Option>), + /// `< count hex… >`. + Raw, + Open, + Close, + /// Anything else, as written. + Other(Vec), +} + +/// The tokens of `text`, each with its line. +struct Tokens<'a> { + text: &'a [u8], + at: usize, + line: usize, + /// The text ended inside a value in parentheses, square brackets or angle brackets. + unclosed: bool, +} + +impl<'a> Tokens<'a> { + /// Past one byte, counting a line at LF, at CR alone, and once for CRLF. + fn step(&mut self) -> Option { + let b = *self.text.get(self.at)?; + if b == b'\n' || b == b'\r' && self.text.get(self.at + 1) != Some(&b'\n') { + self.line += 1; + } + self.at += 1; + Some(b) + } + + /// The bytes up to `end` (not taken in); `None` when the text ends first. + fn until(&mut self, end: u8) -> Option<&'a [u8]> { + let (text, start) = (self.text, self.at); + while text.get(self.at).is_some_and(|&b| b != end) { + self.step(); + } + let inside = text.get(start..self.at)?; + text.get(self.at)?; + self.at += 1; + Some(inside) + } + + fn next_token(&mut self) -> Option<(usize, Token)> { + while self.text.get(self.at).is_some_and(|&b| b.is_ascii_whitespace() || b == 0) { + self.step(); + } + let line = self.line; + let b = *self.text.get(self.at)?; + let ends = |b: u8| b.is_ascii_whitespace() || b == 0 || b"{}[]()<>/".contains(&b); + let token = match b { + b'{' | b'}' => { + self.at += 1; + if b == b'{' { Token::Open } else { Token::Close } + } + b'(' => { + self.at += 1; + let (mut text, mut depth) = (vec![], 0usize); + self.unclosed = true; + while let Some(c) = self.step() { + match c { + b'\\' => text.extend(self.step()), + b'(' => { + depth += 1; + text.push(c); + } + b')' if depth == 0 => { + self.unclosed = false; + break; + } + b')' => { + depth -= 1; + text.push(c); + } + _ => text.push(c), + } + } + Token::Text(text) + } + b'[' | b'<' => { + self.at += 1; + let close = if b == b'[' { b']' } else { b'>' }; + let inside = self.until(close); + self.unclosed = inside.is_none(); + let inside = inside.unwrap_or_default().to_vec(); + if b == b'<' { + Token::Raw + } else { + // The first word is the byte count; the bytes follow in one or more words. + let mut words = inside.split(|c| c.is_ascii_whitespace()).filter(|w| !w.is_empty()); + let count = words.next().and_then(|w| std::str::from_utf8(w).ok()).and_then(|w| w.parse::().ok()); + let digits: Vec = words.flatten().copied().collect(); + Token::Hex(hex_bytes(&digits).filter(|b| Some(b.len()) == count)) + } + } + b'/' => { + self.at += 1; + let start = self.at; + while self.text.get(self.at).is_some_and(|&c| !ends(c)) { + self.at += 1; + } + Token::Name(self.text.get(start..self.at).unwrap_or_default().to_vec()) + } + _ => { + let start = self.at; + while self.text.get(self.at).is_some_and(|&c| !ends(c)) { + self.at += 1; + } + // Always past at least one byte. + self.at = self.at.max(start + 1); + let word = self.text.get(start..self.at).unwrap_or_default(); + let number = word.strip_prefix(b"-").or_else(|| word.strip_prefix(b"+")).unwrap_or(word); + if !number.is_empty() && number.iter().all(u8::is_ascii_digit) { + Token::Int(std::str::from_utf8(word).ok().and_then(|s| s.parse().ok())) + } else { + Token::Other(word.to_vec()) + } + } + }; + Some((line, token)) + } +} + +/// The bytes hexadecimal `digits` stand for, two digits a byte. +fn hex_bytes(digits: &[u8]) -> Option> { + let nibble = |c: u8| (c as char).to_digit(16).and_then(|d| u8::try_from(d).ok()); + if !digits.len().is_multiple_of(2) { + return None; + } + digits.chunks(2).map(|pair| Some((nibble(*pair.first()?)? << 4) | nibble(*pair.get(1)?)?)).collect() +} + +/// What a block is. +enum Kind { + Action, + Step, + Param, + /// A block that isn't read, or one past a cap. + Other, +} + +/// `name` ends with `-` and a number after `prefix`, as `/action-1` does. +fn numbered(name: &[u8], prefix: &[u8]) -> bool { + name.strip_prefix(prefix).is_some_and(|n| !n.is_empty() && n.iter().all(u8::is_ascii_digit)) +} + +/// A name as text: at most [`MAX_NAME`] bytes, read as UTF-8 with replacement characters. +fn name_text(name: &[u8], set: &mut AiaSet) -> String { + if name.len() > MAX_NAME { + set.truncated = true; + } + let mut s = String::from_utf8_lossy(name.get(..MAX_NAME).unwrap_or(name)).into_owned(); + // A character cut at the limit reads as one replacement character. + if name.len() > MAX_NAME && s.ends_with('\u{FFFD}') { + s.pop(); + } + s +} + +/// How the unread list shows a value in square brackets that isn't as many bytes as its count says. +const BAD_HEX: &str = "[ … ] (not the hexadecimal bytes its count says)"; + +/// List a token that isn't read. +fn unread(set: &mut AiaSet, line: usize, text: impl AsRef<[u8]>) { + if set.unread.len() < MAX_UNREAD { + let text: String = String::from_utf8_lossy(text.as_ref()).chars().take(UNREAD_SHOWN).collect(); + set.unread.push((line, text)); + } +} + +/// What the block `name` that opens on `line` inside the blocks `stack` is: a new action, step or +/// recorded value of `set`, or a block that isn't read (listed when it is out of place). +fn open_block(set: &mut AiaSet, stack: &[Kind], name: &[u8], line: usize) -> Kind { + match stack.last() { + _ if stack.len() >= MAX_DEPTH => { + set.truncated = true; + Kind::Other + } + None if numbered(name, b"action-") => { + if set.actions.len() < MAX_ACTIONS { + set.actions.push(AiaAction::default()); + Kind::Action + } else { + set.truncated = true; + Kind::Other + } + } + Some(Kind::Action) if numbered(name, b"event-") => match set.actions.last_mut() { + Some(a) if a.steps.len() < MAX_STEPS && set.steps < MAX_SET_STEPS => { + a.steps.push(AiaStep { line, ..AiaStep::default() }); + set.steps += 1; + Kind::Step + } + _ => { + set.truncated = true; + Kind::Other + } + }, + Some(Kind::Step) if numbered(name, b"parameter-") => { + if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) { + s.params += 1; + } + Kind::Param + } + Some(Kind::Param | Kind::Other) => Kind::Other, + _ => { + unread(set, line, format!("/{} {{", String::from_utf8_lossy(name))); + Kind::Other + } + } +} + +/// Set the field `name` of the block `kind` (the set when `None`) to the hexadecimal text `bytes` +/// on `line`: the set's or an action's `/name`, or a step's `/localizedName`. +fn set_hex(set: &mut AiaSet, kind: Option<&Kind>, name: Option<&[u8]>, bytes: Option>, line: usize) { + let Some(bytes) = bytes else { + set.truncated = true; + unread(set, line, BAD_HEX); + return; + }; + match (kind, name) { + (None, Some(b"name")) => set.name = Some(name_text(&bytes, set)), + (Some(Kind::Action), Some(b"name")) => { + let text = name_text(&bytes, set); + if let Some(a) = set.actions.last_mut() { + a.name = text; + } + } + (Some(Kind::Step), Some(b"localizedName")) => { + let text = name_text(&bytes, set); + if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) { + s.label = Some(text); + } + } + (_, Some(_)) => {} + (_, None) => unread(set, line, "[ … ]"), + } +} + +/// Mark `set` truncated when the hexadecimal `/value` on `line` isn't as many bytes as its count +/// says. +fn check_value(set: &mut AiaSet, bytes: Option>, line: usize) { + if bytes.is_none() { + set.truncated = true; + unread(set, line, BAD_HEX); + } +} + +/// Mark `set` truncated when the field `name` of the block `kind` (the set when `None`) is a count +/// (the set's `/actionCount`, an action's `/eventCount` or a step's `/parameterCount`) whose value is +/// not a number that fits an `i64`. +fn bad_count(set: &mut AiaSet, kind: Option<&Kind>, name: Option<&[u8]>) { + if matches!((kind, name), (None, Some(b"actionCount")) | (Some(Kind::Action), Some(b"eventCount")) | (Some(Kind::Step), Some(b"parameterCount"))) + { + set.truncated = true; + } +} + +/// Set the field `name` of the block `kind` (the set when `None`) to the integer `value` on `line`: +/// the set's `/actionCount`, an action's `/eventCount`, a step's `/parameterCount`, or a step's +/// `/isOn`. A count too large for an `i64` marks the set truncated. +fn set_int(set: &mut AiaSet, kind: Option<&Kind>, name: Option<&[u8]>, value: Option, line: usize) { + if value.is_none() { + bad_count(set, kind, name); + } + match (kind, name) { + (None, Some(b"actionCount")) => set.declared = value, + (Some(Kind::Action), Some(b"eventCount")) => { + if let Some(a) = set.actions.last_mut() { + a.declared = value; + } + } + (Some(Kind::Step), Some(b"parameterCount")) => { + if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) { + s.declared = value; + } + } + (Some(Kind::Step), Some(b"isOn")) => { + if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) { + s.on = value; + } + } + (_, Some(_)) => {} + (_, None) => unread(set, line, value.map_or_else(|| "(a number too large)".to_string(), |v| v.to_string())), + } +} + +/// Read an action set saved as text. An error when the text isn't one: it doesn't start with +/// `/version` and an integer. +pub fn read_aia(bytes: &[u8]) -> std::result::Result { + let mut set = AiaSet::default(); + let text = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes); + let text = match text.get(..MAX_BYTES) { + Some(head) if text.len() > MAX_BYTES => { + set.truncated = true; + head + } + _ => text, + }; + let mut tokens = Tokens { text, at: 0, line: 1, unclosed: false }; + match (tokens.next_token(), tokens.next_token()) { + (Some((_, Token::Name(n))), Some((_, Token::Int(Some(v))))) if n == b"version" => set.version = v, + _ => return Err("not an action set: the text doesn't start with /version and a number".into()), + } + let mut stack: Vec = vec![]; + // A slash name waiting for its value or its `{`: (line, name). + let mut pending: Option<(usize, Vec)> = None; + while let Some((line, token)) = tokens.next_token() { + let in_step = matches!(stack.last(), Some(Kind::Step)); + let field = |pending: &mut Option<(usize, Vec)>| pending.take().map(|(_, n)| n); + match token { + Token::Name(name) => { + if let Some((l, n)) = pending.replace((line, name)) { + bad_count(&mut set, stack.last(), Some(&n)); + unread(&mut set, l, [b"/".as_slice(), &n].concat()); + } + } + Token::Open => { + let (l, name) = pending.take().unwrap_or((line, vec![])); + bad_count(&mut set, stack.last(), Some(&name)); + let kind = open_block(&mut set, &stack, &name, l); + stack.push(kind); + } + Token::Close => { + if let Some((l, n)) = pending.take() { + bad_count(&mut set, stack.last(), Some(&n)); + unread(&mut set, l, [b"/".as_slice(), &n].concat()); + } + if stack.pop().is_none() { + unread(&mut set, line, "}"); + } + } + Token::Hex(bytes) if matches!(stack.last(), Some(Kind::Param)) => { + if field(&mut pending).as_deref() == Some(b"value".as_slice()) { + check_value(&mut set, bytes, line); + } + } + // The other values inside a recorded value, or a block that isn't read. + _ if matches!(stack.last(), Some(Kind::Param | Kind::Other)) => { + pending = None; + } + Token::Hex(bytes) => { + let name = field(&mut pending); + bad_count(&mut set, stack.last(), name.as_deref()); + set_hex(&mut set, stack.last(), name.as_deref(), bytes, line); + } + Token::Text(t) => { + let name = field(&mut pending); + bad_count(&mut set, stack.last(), name.as_deref()); + match name { + Some(n) if in_step && n == b"internalName" => { + let name = name_text(&t, &mut set); + if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) { + s.name = name; + } + } + Some(_) => {} + None => unread(&mut set, line, [b"(".as_slice(), &t, b")"].concat()), + } + } + Token::Int(v) => { + let name = field(&mut pending); + set_int(&mut set, stack.last(), name.as_deref(), v, line); + } + Token::Raw => { + let name = field(&mut pending); + bad_count(&mut set, stack.last(), name.as_deref()); + if name.is_none() { + unread(&mut set, line, "< … >"); + } + } + Token::Other(word) => { + // A real number, or another word as a field's value. + let name = field(&mut pending); + bad_count(&mut set, stack.last(), name.as_deref()); + if name.is_none() { + unread(&mut set, line, word); + } + } + } + } + // The text ends after a field's name, before its value. + if let Some((l, n)) = pending { + set.truncated = true; + unread(&mut set, l, [b"/".as_slice(), &n].concat()); + } + // An unclosed value is the last token: it takes the rest of the text. + if tokens.unclosed { + set.truncated = true; + unread(&mut set, tokens.line, "a value not closed at the end"); + } + if !stack.is_empty() { + set.truncated = true; + unread(&mut set, tokens.line, format!("blocks left open at the end: {}", stack.len())); + } + // Not as many actions, steps or recorded values as the counts give; a negative count never matches. + let differs = |declared: Option, read: usize| declared.is_some_and(|d| !usize::try_from(d).is_ok_and(|d| d == read)); + let step_differs = |a: &AiaAction| a.steps.iter().any(|s| differs(s.declared, s.params)); + if differs(set.declared, set.actions.len()) || set.actions.iter().any(|a| differs(a.declared, a.steps.len()) || step_differs(a)) { + set.truncated = true; + unread(&mut set, tokens.line, "not as many actions, steps or recorded values as the counts give"); + } + Ok(set) +} + +fn read(_: &mut Session, p: &Value) -> Result { + const C: &str = "actionSet.read"; + let (path, data, b64) = (text_param(p, "path", C)?, text_param(p, "data", C)?, text_param(p, "dataBase64", C)?); + let name = text_param(p, "name", C)?; + let bytes: std::borrow::Cow<[u8]> = match (path, data, b64) { + (Some(path), None, None) => super::fileio::read_file_head(path, READ_LIMIT)?.into(), + (None, Some(text), None) => text.as_bytes().get(..READ_LIMIT).unwrap_or(text.as_bytes()).into(), + (None, None, Some(b64)) => super::scripts::decode_head(b64, READ_LIMIT).ok_or_else(|| bad(C, "bad dataBase64"))?.into(), + _ => return Err(bad(C, "give exactly one of `path`, `data` and `dataBase64`")), + }; + let set = read_aia(&bytes).map_err(EngineError::Other)?; + let file = name.or(path).unwrap_or_default(); + let stem = std::path::Path::new(file).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + let name = set.name.clone().filter(|n| !n.trim().is_empty()).unwrap_or(stem); + let actions: Vec = set + .actions + .iter() + .map(|a| { + let steps: Vec = a + .steps + .iter() + .map(|s| { + let label = s.label.clone().filter(|l| !l.trim().is_empty()); + json!({ "name": s.name, "label": label, "on": s.on != Some(0), "params": s.params, "line": s.line }) + }) + .collect(); + json!({ "name": a.name, "steps": steps }) + }) + .collect(); + Ok(json!({ + "set": name, + "version": set.version, + "actions": actions, + "unread": set.unread.iter().map(|(line, text)| json!({ "line": line, "text": text })).collect::>(), + "truncated": set.truncated, + })) +} diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index 4b76562e9..73b21356b 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -962,11 +962,9 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result { let default = CharStyle::default().font_family; let wanted = text_param(p, "font", C)?.filter(|f| !f.trim().is_empty()).unwrap_or(&default); let db = FontDb::global(); - // Any name of the family is stored as its own name. - let family = match db.resolve(wanted, "Regular") { - Some((f, m)) if m != FontMatch::Missing => f.family.clone(), - _ => return Err(bad(C, "the replacement font is not available")), - }; + // Any name of the family is stored as its own name, and a composite font as its own name. + let composite = db.composite_font(wanted).is_some(); + let family = db.stored_family(wanted, "Regular").ok_or_else(|| bad(C, "the replacement font is not available"))?; let missing = |st: &CharStyle| db.resolve(&st.font_family, &st.font_style).is_none_or(|(_, m)| m == FontMatch::Missing); let lacks = |t: &TextObject| t.runs.iter().any(|r| missing(&r.style)); let d = &s.doc()?.doc; @@ -988,8 +986,11 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result { let mut runs = 0; let mut replace = |t: &mut TextObject| { for r in t.runs.iter_mut().filter(|r| missing(&r.style)) { - // The style the font has closest to the one the character had. - r.style.font_style = db.face(&family, &r.style.font_style).map_or_else(|| r.style.font_style.clone(), |f| f.style.clone()); + // The style the font has closest to the one the character had (a composite font's + // parts name their own styles: the character keeps its style). + if !composite { + r.style.font_style = db.face(&family, &r.style.font_style).map_or_else(|| r.style.font_style.clone(), |f| f.style.clone()); + } r.style.font_family = family.clone(); r.style.font_version = None; r.style.font_axes.clear(); @@ -1004,7 +1005,6 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result { } } edit_symbol_texts(d, None, &|t, _| lacks(t), &mut replace); - drop(replace); Ok(runs) })?; Ok(json!({ "runs": runs, "font": family })) diff --git a/crates/engine/src/cmd/addons/color.rs b/crates/engine/src/cmd/addons/color.rs index 574003b93..cd30d29d1 100644 --- a/crates/engine/src/cmd/addons/color.rs +++ b/crates/engine/src/cmd/addons/color.rs @@ -174,7 +174,7 @@ fn filter(s: &mut Session, p: &Value) -> Result { let label = effects::effect_info(id).map_or("Apply as Filter", |e| e.label.trim_end_matches('…')); let kinds = effects::addons::duotone::swatch_kinds(&s.doc()?.doc); let swatches = |name: &str| kinds.get(name).copied(); - recolor_links(s, &given, label, &|c| map.apply(c), &|c, link, tint| map.apply_link(c, link, tint, &swatches)) + recolor_links(s, &given, C, label, &|c| map.apply(c), &|c, link, tint| map.apply_link(c, link, tint, &swatches)) } /// Apply Fill to Stroke (`to_stroke`) or Apply Stroke to Fill: each target copies one of its own diff --git a/crates/engine/src/cmd/addons/color/nearest.rs b/crates/engine/src/cmd/addons/color/nearest.rs index 5a9d0e7c7..0236698be 100644 --- a/crates/engine/src/cmd/addons/color/nearest.rs +++ b/crates/engine/src/cmd/addons/color/nearest.rs @@ -189,7 +189,7 @@ pub(super) fn apply(s: &mut Session, p: &Value) -> Result { let given = json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "fill": fill, "stroke": stroke, "includeImages": false, "includePatterns": false, }); - let r = recolor_links(s, &given, "Apply Nearest Swatch", &|c| to(c).and_then(Paint::color).unwrap_or(c), &links)?; + let r = recolor_links(s, &given, APPLY, "Apply Nearest Swatch", &|c| to(c).and_then(Paint::color).unwrap_or(c), &links)?; squash(s, undo, "Apply Nearest Swatch"); Ok(json!({ "changed": r["changed"], "swatches": names, "added": added })) } diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 30e48d4fc..be452f6c6 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Add New Fill", ["Window", "Appearance"], Some("Cmd+/"), - "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, |s, p| add_item(s, p, true) ), @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Add New Stroke", ["Window", "Appearance"], Some("Cmd+Alt+/"), - "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, |s, p| add_item(s, p, false) ), @@ -63,7 +63,7 @@ pub fn specs() -> Vec { "Clear Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md)", has_doc, clear_appearance ), @@ -72,7 +72,7 @@ pub fn specs() -> Vec { "Reduce to Basic Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, reduce_basic ), @@ -81,7 +81,7 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, set_item ), @@ -90,7 +90,7 @@ pub fn specs() -> Vec { "Remove Item", [], None, - "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, remove_item ), @@ -108,7 +108,7 @@ pub fn specs() -> Vec { "Duplicate Item", ["Window", "Appearance"], None, - "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, duplicate_item ), @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Reorder Appearance Item", [], None, - "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}", + "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}", has_doc, move_item ), @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art). On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's solid fill and stroke for this instance only, as the Eyedropper Options allow, and a sampled gradient or pattern, a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}", has_doc, copy_from ), @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Show All Hidden Attributes", ["Window", "Appearance"], None, - "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", + "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", has_doc, show_all_hidden ), @@ -162,7 +162,7 @@ pub fn specs() -> Vec { "Move Appearance", [], None, - "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {source, target}", + "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {source, target}", has_doc, transfer ), @@ -598,6 +598,9 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { n.appearance.items.push(item); added.push((*id, fill)); } + if !fill { + super::object::snap_if_aligned(n); + } } } super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); @@ -620,29 +623,38 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { for id in &ids { if let Some(n) = d.node_mut(*id) { clear(n); + super::object::snap_if_aligned(n); } } let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) + let object = super::graph::capture_series_object_opacity(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || object)) })?; s.active_appearance_item = None; ok() } +/// Each of `ids` whose object opacity or blend mode is another in `d` than in `before`, for +/// [`super::graph::capture_series_object_opacity`]. +fn object_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec { + let of = |d: &vectorcraft_doc::Document, id: NodeId| d.node(id).map(|n| (n.opacity, n.blend)); + ids.iter().copied().filter(|id| of(before, *id) != of(d, *id)).collect() +} + /// Each of `ids` with both kinds of row (fill, then stroke), for [`super::graph::capture_series_opacity`] after an /// edit that replaces whole stacks. fn both_kinds(ids: &[NodeId]) -> Vec<(NodeId, bool)> { ids.iter().flat_map(|id| [(*id, true), (*id, false)]).collect() } -/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another opacity in `d` -/// than in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`]. +/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another opacity or blend +/// mode in `d` than in `before` (no row counts as opaque and Normal), for [`super::graph::capture_series_opacity`]. fn top_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> { let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| { d.node(id) - .and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()).map(AppearanceItem::opacity)) - .unwrap_or(1.0) + .and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()).map(|it| (it.opacity(), it.blend()))) + .unwrap_or((1.0, BlendMode::Normal)) }; ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect() } @@ -661,6 +673,43 @@ fn painted_rows(d: &vectorcraft_doc::Document, rows: impl IntoIterator bool { + let top_shows = |d: &vectorcraft_doc::Document, id: NodeId, kind: bool| { + let items = d.node(id).map_or(&[][..], |n| &n.appearance.items[..]); + items.iter().rev().find(|it| it.is_fill() == kind).is_some_and(AppearanceItem::visible) + }; + let kinds = |(fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> { + let rows = ids.iter().flat_map(|id| [fill.then_some((*id, true)), stroke.then_some((*id, false))]).flatten(); + rows.filter(|(id, kind)| !top_only || top_shows(d, *id, *kind)).collect() + }; + let written = |d: &vectorcraft_doc::Document, rows: (bool, bool)| painted_rows(d, kinds(rows)); + let mut opacity_rows = top_opacity_changed(before, d, ids); + // A written row that had paint before the edit counts too when the edit left it without paint: only a row without + // paint both before and after keeps what the graph stores. + opacity_rows.extend(painted_rows(before, kinds(opacities))); + opacity_rows.extend(written(d, opacities)); + let mut rows = top_paint_changed(before, d, ids); + rows.extend(written(d, paints)); + let kept = super::graph::capture_series_opacity(d, &opacity_rows); + let painted = super::graph::capture_series_top_paints(d, &rows); + let objects = if object { ids.to_vec() } else { object_opacity_changed(before, d, ids) }; + let stored = super::graph::capture_series_object_opacity(d, &objects); + kept || painted || stored +} + /// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`. fn top_row_kind(ap: &Appearance, at: usize) -> Option { let fill = ap.items.get(at)?.is_fill(); @@ -698,9 +747,11 @@ fn transfer(s: &mut Session, p: &Value) -> Result { } to.appearance = ap; (to.opacity, to.blend, to.isolate, to.knockout, to.knockout_shape) = (src.opacity, src.blend, src.isolate, src.knockout, src.knockout_shape); + super::object::snap_if_aligned(to); if !copy && let Some(n) = d.node_mut(source) { clear(n); (n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false); + super::object::snap_if_aligned(n); } let changed = if copy { vec![target] } else { vec![source, target] }; let kept = super::graph::capture_series_opacity(d, &both_kinds(&changed)); @@ -708,7 +759,8 @@ fn transfer(s: &mut Session, p: &Value) -> Result { let mut rows = top_paint_changed(&before, d, &changed); rows.extend(painted_rows(d, both_kinds(&[target]))); let painted = super::graph::capture_series_top_paints(d, &rows); - Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted)) + let object = super::graph::capture_series_object_opacity(d, &changed); + Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted || object)) })?; s.active_appearance_item = None; Ok(json!({ "source": source.0, "target": target.0 })) @@ -759,6 +811,7 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result { st.blend = BlendMode::Normal; n.appearance.items.push(AppearanceItem::Stroke(st)); } + super::object::snap_if_aligned(n); } } let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); @@ -818,6 +871,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("visible").and_then(Value::as_bool) { *vis = v; } + super::object::snap_if_aligned(n); let items = &n.appearance.items; if let Some(fill) = items.get(idx).map(AppearanceItem::is_fill).filter(|f| items.iter().rposition(|it| it.is_fill() == *f && it.visible()) == Some(idx)) @@ -825,10 +879,10 @@ fn set_item(s: &mut Session, p: &Value) -> Result { edited.push((*id, fill)); } } - // The graph keeps an opacity, paint or weight set on the topmost visible fill or stroke of its series art for when - // it is drawn again, and hiding or showing a row changes which row that is. + // The graph keeps an opacity, blend mode, paint or weight set on the topmost visible fill or stroke of its series art + // for when it is drawn again, and hiding or showing a row changes which row that is. let mut opacities = top_opacity_changed(&before, d, &ids); - if p.get("opacity").and_then(Value::as_f64).is_some() { + if p.get("opacity").and_then(Value::as_f64).is_some() || blend.is_some() { opacities.extend(edited.iter().copied()); } let kept = super::graph::capture_series_opacity(d, &opacities); @@ -878,6 +932,7 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { for &i in idx.iter().rev() { n.appearance.remove_item(i); } + super::object::snap_if_aligned(n); } } let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); @@ -982,7 +1037,19 @@ fn show_all_hidden(s: &mut Session, p: &Value) -> Result { let roots = require_targets(s, p, "appearance.showAllHidden")?; let before = s.doc()?.doc.clone(); let ids = s.edit("Show All Hidden Attributes", |d, _| { - let shown: Vec = roots.iter().copied().filter(|id| d.node_mut(*id).is_some_and(|n| n.appearance.show_all())).collect(); + let shown: Vec = roots + .iter() + .copied() + .filter(|id| { + d.node_mut(*id).is_some_and(|n| { + let shown = n.appearance.show_all(); + if shown { + super::object::snap_if_aligned(n); + } + shown + }) + }) + .collect(); if shown.is_empty() { return Err(EngineError::Other("Show All Hidden Attributes: nothing is hidden".into())); } @@ -1136,6 +1203,42 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "`defaultsOnly` cannot be combined with `ids`, `reverse` or `append`")); } let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?; + // An object of a dynamic symbol instance's art selected with Direct Selection takes the clicked + // object's solid fill and stroke, as the Eyedropper Options allow, for this instance only. A + // gradient or pattern sampled for it, a source that is not a path, compound path or type (a + // symbol instance's own appearance is empty), and options that pick up neither color are + // refused, and the paint defaults stay as they are. A path of a compound path stands for the + // compound path, which holds the paint. + if !reverse + && !append + && !defaults_only + && p.get("ids").is_none() + && let Some((id, path)) = super::brushsym::selected_part(s)? + { + let d = &s.doc()?.doc; + let src = Arc::new(d.node(super::path::compound_or_self(d, clicked)).ok_or(EngineError::NoNode(clicked))?.clone()); + let (fill, stroke) = (super::paint::proxy_paint(&src, false, None), super::paint::proxy_paint(&src, true, None)); + let a = attrs.appearance; + if !vectorcraft_doc::symbol_overrides::takes_part_paint(&src) + || !(a.fill.color || a.stroke.color) + || (a.fill.color && !super::paint::part_takes(&fill)) + || (a.stroke.color && !super::paint::part_takes(&stroke)) + { + return Err(super::brushsym::part_only()); + } + super::brushsym::edit_part(s, id, &path, "Eyedropper", |o| { + if a.fill.color { + o.fill = Some(fill); + } + if a.stroke.color { + o.stroke = Some(stroke); + } + })?; + // The clicked object becomes the Eyedropper's sample, and new art takes its paint. + Picked::of(&src).to_defaults(s, &attrs.appearance); + s.eyedropper_sample = Some(src); + return Ok(json!({ "ids": [id.0] })); + } let range = if !defaults_only && !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { @@ -1144,7 +1247,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { let selected = if defaults_only { vec![] } else { - match ids_param(p, "ids") { + match ids_param(p, "ids", C)? { Some(v) => outermost(&s.doc()?.doc, v), None => selected_roots(s)?, } @@ -1261,7 +1364,10 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { rows.extend(painted_rows(d, written(paints))); let kept = super::graph::capture_series_opacity(d, &opacity_rows); let painted = super::graph::capture_series_top_paints(d, &rows); - Ok(count_changed(d, &before, &targets) + usize::from(kept || painted)) + // A pick of the object's transparency writes it on every target. + let objects = if attrs.appearance.transparency { targets.clone() } else { object_opacity_changed(&before, d, &targets) }; + let object = super::graph::capture_series_object_opacity(d, &objects); + Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object)) })?; Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() })) } @@ -1343,6 +1449,7 @@ impl Picked { let placed = self.placed(n.geometric_bounds()); n.appearance.items.extend(placed.items.into_iter().filter(|it| if it.is_fill() { ap.fill.color } else { ap.stroke.color })); n.appearance.effects.extend(placed.effects); + super::object::snap_if_aligned(n); return; } if let NodeKind::Text(t) = &mut n.kind { @@ -1366,6 +1473,7 @@ impl Picked { } else { focal_attrs(&mut n.appearance, &placed, ap); } + super::object::snap_if_aligned(n); } } diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index 133fb1fce..8c225b877 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -3,12 +3,17 @@ //! settings Export for Screens shares (`document.exportSettings`). File → Export Selection collects //! the selection this way and opens Export for Screens on its Assets tab (a UI command). +use std::collections::HashSet; + use serde_json::{Map, Value, json}; use vectorcraft_doc::{ExportAsset, NodeId}; use super::edit::{roots_of, selected_roots}; -use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_crop, export_screens, store_screen_settings, with_screen_export_preset}; -use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, has_doc, ok, str_param}; +use super::fileio::{ + DEFAULT_FORMAT_3D, FORMATS_3D, SCREEN_SETTINGS_KEYS, check_screen_settings, create_dir, export_crop, export_screens, mesh_file_names, mesh_files, + safe_file_name, store_screen_settings, with_screen_export_preset, write_file, +}; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, has_doc, ok, str_param, switch_or, text_param}; /// The longest asset name kept (characters). const MAX_NAME: usize = 255; @@ -39,7 +44,7 @@ pub fn specs() -> Vec { "Assets", [], None, - "{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included)}]} the Asset Export panel's assets, in order", + "{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included), format3D: usda|usdz|gltf|obj | null (a 3D asset's mesh format; null for art exported as pictures)}]} the Asset Export panel's assets, in order", has_doc, list ), @@ -48,10 +53,37 @@ pub fn specs() -> Vec { "Export Assets", [], None, - "{assets?: [asset id…] (default all), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)", + "{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)", has_doc, export ), + cmd!( + "assets.add3D", + "Export 3D Object", + [], + None, + "{ids?: [id…] (default: the selected objects), format?: usda|usdz|gltf|obj (default usda)} collect each object with an Extrude & Bevel, Revolve, Inflate or Rotate effect as a 3D asset of Window › Asset Export, named after the object (else Asset 1, Asset 2…): assets.export3D writes it as a mesh file. An object already collected as a 3D asset keeps it; an object without such an effect is refused → {assets: [asset id…] (in order), added: n}", + has_doc, + add_3d + ), + cmd!( + "assets.set3DFormat", + "3D Export Format", + [], + None, + "{assets: [asset id…], format: usda|usdz|gltf|obj} the mesh format 3D assets export to (Asset Export's Format for a 3D asset) → {changed: n}", + has_doc, + set_3d_format + ), + cmd!( + "assets.export3D", + "Export 3D Assets", + [], + None, + "{assets?: [asset id…] (default every 3D asset), folder?, zip?} each 3D asset's object as a mesh file in its format (assets.set3DFormat), named after the asset: USDA or USDZ, glTF 2.0 (one .gltf, its buffer embedded) or OBJ with its .mtl (whitespace and # in an OBJ's name become _). Every solid of the object (the fill's, each stroke's, a group's members') is a mesh with a material in the color it is painted; faces turned away from the viewer are included, at the object's 3D rotation, y up, about the point it turns about, in points (glTF: meters). Files that would share a name get -2, -3…; an object with a hole that crosses its face's outline or another hole, or with more than 2,000,000 triangles, is refused → {files: [path…]} (no folder: {files: [{name, dataBase64}]}, or with zip one zip as {name, dataBase64, files})", + has_doc, + export_3d + ), cmd!( "assets.settings.set", "Asset Export Settings", @@ -115,7 +147,9 @@ fn add(s: &mut Session, p: &Value) -> Result { if name.is_some() && sets.len() > 1 { return Err(bad(C, "a name names one asset: give multiple: false, or one object")); } - let existing = |d: &vectorcraft_doc::Document, nodes: &[NodeId]| d.assets.iter().find(|a| a.nodes == nodes).map(|a| a.id); + // A 3D asset of the same art is another asset. + let existing = + |d: &vectorcraft_doc::Document, nodes: &[NodeId]| d.assets.iter().find(|a| a.nodes == nodes && a.format_3d.is_none()).map(|a| a.id); let d = &s.doc()?.doc; // Nothing new: no undo step. if sets.iter().all(|set| existing(d, set).is_some()) { @@ -134,7 +168,7 @@ fn add(s: &mut Session, p: &Value) -> Result { }; let name = name.clone().or(own).unwrap_or_else(|| d.next_asset_name()); let id = d.alloc_id().0; - d.assets.push(ExportAsset { id, name, nodes }); + d.assets.push(ExportAsset { id, name, nodes, format_3d: None }); out.push(id); added += 1; } @@ -142,6 +176,161 @@ fn add(s: &mut Session, p: &Value) -> Result { }) } +/// The format of param `format` of command `cmd` ([`FORMATS_3D`]), `default` when it is not given. +fn format_3d_param(p: &Value, cmd: &str, default: Option<&str>) -> Result { + match (p.get("format"), default) { + (None, Some(d)) => Ok(d.to_string()), + (Some(Value::String(f)), _) if FORMATS_3D.contains(&f.as_str()) => Ok(f.clone()), + _ => { + let key = "format"; + Err(bad(cmd, format!("`{key}` must be one of {}", FORMATS_3D.join(", ")))) + } + } +} + +/// The asset ids of param `key` of command `cmd`, each a 3D asset of `d`. +fn three_d_asset_ids(d: &vectorcraft_doc::Document, p: &Value, key: &str, cmd: &str) -> Result> { + let ids = asset_ids(p, key, cmd)?; + for id in &ids { + match d.asset(*id) { + None => return Err(bad(cmd, "no such assets (see assets.list)")), + Some(a) if a.format_3d.is_none() => return Err(bad(cmd, format!("asset {id} isn't a 3D asset"))), + _ => {} + } + } + Ok(ids) +} + +fn add_3d(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.add3D"; + let format = format_3d_param(p, C, Some(DEFAULT_FORMAT_3D))?; + let ids = if p.get("ids").is_some_and(|v| !v.is_null()) { checked_ids_param(s, p, "ids", C)? } else { selected_roots(s)? }; + if ids.is_empty() { + return Err(bad(C, "select an object with a 3D effect (or give its ids)")); + } + let d = &s.doc()?.doc; + let mut ids = d.paint_order(ids); + ids.dedup(); + for id in &ids { + let n = d.node(*id).ok_or_else(|| bad(C, format!("no such object {0}", id.0)))?; + if let Err(e) = vectorcraft_render::effects::three_d_meshes(n) { + let what = n.name.clone().unwrap_or_else(|| format!("#{}", id.0)); + return Err(bad(C, format!("{what} can't be exported in 3D: {e}"))); + } + } + let existing = |d: &vectorcraft_doc::Document, id: NodeId| d.assets.iter().find(|a| a.nodes == [id] && a.format_3d.is_some()).map(|a| a.id); + // Nothing new: no undo step. + if ids.iter().all(|id| existing(d, *id).is_some()) { + return Ok(json!({ "assets": ids.iter().filter_map(|id| existing(d, *id)).collect::>(), "added": 0 })); + } + s.edit("Export 3D Object", |d, _| { + let (mut out, mut added) = (vec![], 0); + for node in ids { + if let Some(id) = existing(d, node) { + out.push(id); + continue; + } + let name = d.node(node).and_then(|n| n.name.as_deref()).and_then(clean_name).unwrap_or_else(|| d.next_asset_name()); + let id = d.alloc_id().0; + d.assets.push(ExportAsset { id, name, nodes: vec![node], format_3d: Some(format.clone()) }); + out.push(id); + added += 1; + } + Ok(json!({ "assets": out, "added": added })) + }) +} + +fn set_3d_format(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.set3DFormat"; + let d = &s.doc()?.doc; + let ids = three_d_asset_ids(d, p, "assets", C)?; + let format = format_3d_param(p, C, None)?; + let changed = d.assets.iter().filter(|a| ids.contains(&a.id) && a.format_3d.as_deref() != Some(format.as_str())).count(); + // Nothing changes: no undo step. + if changed == 0 { + return Ok(json!({ "changed": 0 })); + } + s.edit("3D Export Format", |d, _| { + for a in d.assets.iter_mut().filter(|a| ids.contains(&a.id)) { + a.format_3d = Some(format.clone()); + } + Ok(json!({ "changed": changed })) + }) +} + +fn export_3d(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.export3D"; + let d = &s.doc()?.doc; + let ids = if p.get("assets").is_some_and(|v| !v.is_null()) { + three_d_asset_ids(d, p, "assets", C)? + } else { + d.assets.iter().filter(|a| a.format_3d.is_some()).map(|a| a.id).collect() + }; + if ids.is_empty() { + return Err(bad(C, "no 3D assets yet: collect an object with a 3D effect first (assets.add3D)")); + } + let folder = text_param(p, "folder", C)?.filter(|f| !f.is_empty()); + let zip = switch_or(p, "zip", false, C)?; + // Every file's name, compared without case: a later file that would take one gets -2, -3… + let mut taken: HashSet = HashSet::new(); + let mut files: Vec<(String, Vec)> = vec![]; + for id in ids { + let Some(a) = d.asset(id) else { continue }; + let format = a.format_3d.as_deref().unwrap_or(DEFAULT_FORMAT_3D); + let nodes = + a.nodes.iter().map(|node| d.node(*node).ok_or_else(|| bad(C, format!("no such object {0}", node.0)))).collect::>>()?; + // The asset's objects share one budget of triangles. + let what = &a.name; + let parts = vectorcraft_render::effects::three_d_meshes_of(nodes).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?; + let base = Some(safe_file_name(&a.name)).filter(|b| !b.is_empty()).unwrap_or_else(|| "Asset".into()); + let mut stem = base.clone(); + let mut k = 1; + loop { + let names = mesh_file_names(&stem, format).map_err(|e| bad(C, e))?; + if names.iter().all(|n| !taken.contains(&n.to_lowercase())) { + break; + } + k += 1; + stem = format!("{base}-{k}"); + } + let made = mesh_files(&stem, format, &parts).map_err(|e| bad(C, e))?; + taken.extend(made.iter().map(|(n, _)| n.to_lowercase())); + files.extend(made); + } + let names: Vec<&str> = files.iter().map(|(n, _)| n.as_str()).collect(); + if zip { + let archive = super::fileio::zip::store(&files).map_err(|e| bad(C, e))?; + // "-3D" keeps it apart from the picture assets' ZIP, which is named after the document. + let name = Some(safe_file_name(&d.title)).filter(|t| !t.is_empty()).map_or_else(|| "3D Assets.zip".into(), |t| format!("{t}-3D.zip")); + return match folder { + Some(dir) => { + create_dir(dir)?; + let path = format!("{dir}/{name}"); + write_file(&path, &archive)?; + Ok(json!({ "path": path, "bytes": archive.len(), "files": names })) + } + None => Ok(json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(&archive), "bytes": archive.len(), "files": names })), + }; + } + match folder { + Some(dir) => { + create_dir(dir)?; + let mut written = vec![]; + for (name, bytes) in &files { + let path = format!("{dir}/{name}"); + write_file(&path, bytes)?; + written.push(path); + } + Ok(json!({ "files": written })) + } + None => { + let files: Vec = + files.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect(); + Ok(json!({ "files": files })) + } + } +} + /// The asset ids of param `key`. fn asset_ids(p: &Value, key: &str, cmd: &str) -> Result> { let ids = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{key}` must be an array of asset ids (see assets.list)")))?; @@ -186,6 +375,7 @@ fn list(s: &mut Session, _: &Value) -> Result { "name": a.name, "nodes": a.nodes.iter().map(|n| n.0).collect::>(), "bounds": bounds.map(|b| [b.x0, b.y0, b.width(), b.height()]), + "format3D": a.format_3d, }) }) .collect(); @@ -227,8 +417,10 @@ fn export(s: &mut Session, p: &Value) -> Result { if q.get("subfolders").and_then(Value::as_bool) == Some(false) && p.get("folders").is_none() { q.insert("folders".into(), json!("none")); } + let d = &s.doc()?.doc; + // 3D assets are written as mesh files, by assets.export3D: the export refuses one it is given. if !q.contains_key("assets") { - let all: Vec = s.doc()?.doc.assets.iter().map(|a| a.id).collect(); + let all: Vec = d.assets.iter().filter(|a| a.format_3d.is_none()).map(|a| a.id).collect(); if all.is_empty() { return Err(bad(C, "no assets yet: collect art for export first (assets.add)")); } diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 8cf3a2195..53f373a3b 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -36,7 +36,8 @@ use brushes::{ brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_remove, brush_selection_art, brush_set_current, brush_unused, expand_brush, }; -use dynamic::{symbol_instance_reset, symbol_instance_set_appearance}; +pub(crate) use dynamic::{edit_part, part_only, selected_part}; +use dynamic::{symbol_instance_reset, symbol_instance_select_part, symbol_instance_set_appearance}; use symbols::selected_instances; pub(crate) use symbols::{ expand_instances_under, expandable_instances, instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol, @@ -262,10 +263,19 @@ pub fn specs() -> Vec { "Symbol Instance Appearance", [], None, - "{id?|ids?: [one id] (default: the one selected object), path: [i…] (the object in the symbol's art: child indices in paint order from the art's root down through its groups; [] the root), fill?: \"#rrggbb\"|{…paint params of paint.setFill: color, none, swatch, tint, gradient, keepModel}|null (null: the symbol's own again), stroke?: as fill, opacity?: 0..100|null, blend?: name|null} change the appearance of one object of a dynamic symbol instance's art, for that instance only: it stays linked, so editing the symbol reshapes it and keeps the change; refused for a static symbol or a path naming no object → {id, overrides: how many objects the instance changes}", + "{id?|ids?: [one id] (default: the one selected object), path: [i…] (the object in the symbol's art: child indices in paint order from the art's root down through its groups; [] the root), fill?: \"#rrggbb\"|{…paint params of paint.setFill: color, none, swatch, tint, gradient, keepModel}|null (null: the symbol's own again), stroke?: as fill, opacity?: 0..100|null, blend?: name|null} change the appearance of one object of a dynamic symbol instance's art, for that instance only: it stays linked, so editing the symbol reshapes it and keeps the change; refused for a static symbol, a path naming no object, or a fill or stroke other than null for an object that does not draw it (a guide, blend, envelope, Repeat, Intertwine group, gradient mesh or placed document; a symbol instance's stroke, and its fill unless a solid color or none, which stains its art; an image's fill or stroke without a Convert to Shape or Outline Object effect), with nothing of the call applied; opacity, blend and null are taken for every object → {id, overrides: how many objects the instance changes}", has_selection, symbol_instance_set_appearance ), + cmd!( + "symbol.instance.selectPart", + "Select Symbol Instance Part", + [], + None, + "{id, path: [i…] (as symbol.instance.setAppearance)} select one path, compound path or type object of a dynamic symbol instance's art, as a Direct Selection click on it does: the instance is selected alone, and until the selection changes paint.setFill and paint.setStroke with a solid color or none, paint.invert and paint.complement on a solid color (without ids, id or item; type whose characters differ in color is refused) and appearance.copyFrom with a solid fill and stroke (the Eyedropper, without ids, reverse or append; options that pick up neither color are refused) change that object's fill or stroke for this instance only; gradients and patterns are refused, and so are the commands that change the selected objects without ids (transparency.set, the gradient commands, object.group, edit.cut and edit.clear among them); edit.copy copies the instance whole, and selection exports, artboard.fitToSelection, object.liquify, object.isolate, library.add and select.save use the instance (docs/mcp.md); selectPart itself is refused for a static symbol, a path naming no object, a group, image or other kind of object, a hidden, locked or template object or one inside such a group, and a hidden or locked instance → {id, path}", + has_doc, + symbol_instance_select_part + ), cmd!( "symbol.instance.reset", "Reset Symbol Instance", diff --git a/crates/engine/src/cmd/brushsym/dynamic.rs b/crates/engine/src/cmd/brushsym/dynamic.rs index baa721c45..89c7c3c52 100644 --- a/crates/engine/src/cmd/brushsym/dynamic.rs +++ b/crates/engine/src/cmd/brushsym/dynamic.rs @@ -61,6 +61,62 @@ fn dynamic_instance(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, Arc Result)>> { + Ok(s.doc()?.selection.symbol_part.as_ref().map(|sp| (sp.instance, sp.path.clone()))) +} + +/// Change instance `id`'s own appearance for the object at `path` of its art by `f`, as one undo +/// step named `label`; nothing is recorded when the change leaves it as it was. Replies how many +/// objects the instance changes. +pub(crate) fn edit_part(s: &mut Session, id: NodeId, path: &[u32], label: &str, f: impl FnOnce(&mut SymbolOverride)) -> Result { + let list = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?.extras().symbol_overrides.clone(); + let old = list.iter().find(|o| o.path == path).cloned().unwrap_or_else(|| SymbolOverride { path: path.to_vec(), ..Default::default() }); + let mut new = old.clone(); + f(&mut new); + if new == old { + return Ok(list.len()); + } + s.edit(label, |d, _| { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + let mut e = n.extras_mut(); + let list = &mut e.symbol_overrides; + list.retain(|o| o.path != path); + if !new.is_empty() { + list.push(new); + } + Ok(list.len()) + }) +} + +/// The refusal of anything but a solid color fill or stroke change while an object of a symbol +/// instance's art is selected ([`selected_part`]), and of selecting any other object of the art. +pub(crate) fn part_only() -> EngineError { + EngineError::Other("Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change".into()) +} + +pub(super) fn symbol_instance_select_part(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.instance.selectPart"; + if p.get("id").is_none() { + return Err(bad(C, "missing id")); + } + let (id, art) = dynamic_instance(s, p, C)?; + let path = path_param(p, &art, C)?; + // Direct Selection passes over hidden, locked and template objects, and over everything in a + // group or layer that is: the instance in the document, and the object in the symbol's art. + let editable = |n: &Node| n.visible && !n.locked && !n.is_template(); + if !s.doc()?.doc.is_editable(id) || !(0..=path.len()).all(|k| symbol_overrides::part(&art, &path[..k]).is_some_and(editable)) { + return Err(bad(C, "the object is hidden or locked")); + } + if !symbol_overrides::part(&art, &path).is_some_and(symbol_overrides::takes_part_paint) { + return Err(part_only()); + } + let shown = json!(path); + s.select(|_, sel| sel.set_symbol_part(id, path))?; + Ok(json!({ "id": id.0, "path": shown })) +} + /// The `path` param: child indices from the art's root, naming an object of `art`. fn path_param(p: &Value, art: &Node, cmd: &str) -> Result> { let path: Option> = @@ -73,6 +129,64 @@ fn path_param(p: &Value, art: &Node, cmd: &str) -> Result> { Ok(path) } +/// The refusal of `key` (`fill` or `stroke`) for `n`, an object of a dynamic symbol's art, when `n` +/// does not draw that paint of its own; `None` when it does. Paths (not guides), +/// compound paths, type, groups and layers draw them; a symbol instance draws its fill as a stain +/// on its art ([`solid_fill_check`]) and no stroke; an image draws one only when that fill or +/// stroke has a Convert to Shape or Outline Object effect. +fn paint_refusal(n: &Node, key: &str, cmd: &str) -> Option { + Some(match &n.kind { + NodeKind::Path { guide: true, .. } => bad(cmd, format!("{key} cannot be set on a guide")), + NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) | NodeKind::Group { .. } | NodeKind::Layer { .. } => return None, + NodeKind::Image(_) => { + let effects = if key == "fill" { n.appearance.fill().map(|f| &f.effects) } else { n.appearance.stroke().map(|s| &s.effects) }; + if effects.is_some_and(|e| vectorcraft_render::effects::gives_image_shape(e)) { + return None; + } + bad(cmd, format!("{key} cannot be set on an image without a Convert to Shape or Outline Object effect on it")) + } + NodeKind::SymbolInstance { .. } if key == "fill" => return None, + NodeKind::SymbolInstance { .. } => bad(cmd, "stroke cannot be set on a symbol instance"), + NodeKind::Blend { .. } => bad(cmd, format!("{key} cannot be set on a blend")), + NodeKind::Envelope { .. } => bad(cmd, format!("{key} cannot be set on an envelope")), + NodeKind::Mesh(_) => bad(cmd, format!("{key} cannot be set on a gradient mesh")), + NodeKind::Repeat(_) => bad(cmd, format!("{key} cannot be set on a Repeat")), + NodeKind::PlacedDocument(_) => bad(cmd, format!("{key} cannot be set on a placed document")), + NodeKind::Intertwine { .. } => bad(cmd, format!("{key} cannot be set on an Intertwine group")), + }) +} + +/// Refuse a `fill` or `stroke` other than null that the object at `path` of `art` does not draw +/// ([`paint_refusal`]); null, which takes a change back, is taken for every object. +fn check_paint_kinds(p: &Value, art: &Node, path: &[u32], cmd: &str) -> Result<()> { + let Some(n) = symbol_overrides::part(art, path) else { return Ok(()) }; + for key in ["fill", "stroke"] { + if p.get(key).is_some_and(|v| !v.is_null()) + && let Some(e) = paint_refusal(n, key, cmd) + { + return Err(e); + } + } + Ok(()) +} + +/// Refuse `fill`, the paint `p` asks for the object at `path` of `art`, when that object is a +/// symbol instance and `fill` is a gradient or a pattern: an instance stains its art with a solid +/// fill only, so such a fill draws nothing. +fn solid_fill_check(p: &Value, art: &Node, path: &[u32], fill: Option<&vectorcraft_color::Paint>, cmd: &str) -> Result<()> { + let asked = p.get("fill").is_some_and(|v| !v.is_null()); + let is_instance = symbol_overrides::part(art, path).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })); + let draws_nothing = |f: &vectorcraft_color::Paint| match f { + vectorcraft_color::Paint::Gradient(_) => true, + vectorcraft_color::Paint::Pattern { .. } => true, + vectorcraft_color::Paint::None | vectorcraft_color::Paint::Solid { .. } => false, + }; + if asked && is_instance && fill.is_some_and(draws_nothing) { + return Err(bad(cmd, "fill on a symbol instance must be a solid color or none")); + } + Ok(()) +} + /// `fill` or `stroke` of `p` for override `o`: absent leaves it, null clears it, a colour string or /// an object of `paint.setFill`'s paint params sets it. fn paint_change(s: &Session, p: &Value, key: &str, cmd: &str) -> Result>> { @@ -114,19 +228,12 @@ pub(super) fn symbol_instance_set_appearance(s: &mut Session, p: &Value) -> Resu } let (id, art) = dynamic_instance(s, p, C)?; let path = path_param(p, &art, C)?; + check_paint_kinds(p, &art, &path, C)?; let d = &s.doc()?.doc; let old = d.node(id).and_then(|n| n.extras().symbol_overrides.iter().find(|o| o.path == path).cloned()); let new = changed(s, p, old.unwrap_or_else(|| SymbolOverride { path: path.clone(), ..Default::default() }), C)?; - let count = s.edit("Symbol Instance Appearance", |d, _| { - let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - let mut e = n.extras_mut(); - let list = &mut e.symbol_overrides; - list.retain(|o| o.path != path); - if !new.is_empty() { - list.push(new); - } - Ok(list.len()) - })?; + solid_fill_check(p, &art, &path, new.fill.as_ref(), C)?; + let count = edit_part(s, id, &path, "Symbol Instance Appearance", |o| *o = new)?; Ok(json!({ "id": id.0, "overrides": count })) } diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 69bfcb66e..60353921e 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -888,6 +888,8 @@ pub(super) fn symbol_adjust(s: &mut Session, p: &Value) -> Result { let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0); let intensity = number_or(p, "intensity", 5.0, C)?.clamp(1.0, 10.0) / 10.0; let alt = switch_or(p, "alt", false, C)?; + // Refused while an object of a dynamic instance's art is selected: the instance stays as it is. + crate::cmd::edit::refuse_symbol_part(s)?; let st = s.doc()?; let color = match color_param(p, "color", C)? { Some(c) => crate::cmd::paint::new_color(s, p, c), diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 442b68c58..d39244924 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Image Trace"], None, - "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}", + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}", has_selection_or_ids, |s, p| trace_make(s, p, false) ), @@ -153,7 +153,7 @@ pub fn specs() -> Vec { "Make and Expand", ["Object", "Image Trace"], None, - "{…as imageTrace.make} same as imageTrace.make, then expand → {id, paths, anchors, colors}", + "{…as imageTrace.make, byColor?: false (each color's paths in a group of their own, as imageTrace.expand)} same as imageTrace.make, then expand → {id, paths, anchors, colors, warnings: [text…] (as imageTrace.make)}", has_selection_or_ids, |s, p| trace_make(s, p, true) ), @@ -171,7 +171,7 @@ pub fn specs() -> Vec { "Expand", ["Object", "Image Trace"], None, - "{id?, ids?: [id…] (instead of id)} keep only the traced paths (drop the source image) → {ids}", + "{id?, ids?: [id…] (instead of id), byColor?: false (each color's paths in a group of their own, named by the swatch the color links to or as #rrggbb, gradient fills in one group named Gradients; the groups stack by their lowest path and keep their paths' order; a color whose paths overlap another color's from above and below gets a group for each part, so the art draws the same; refused for a trace made with autoGrouping and for a trace with a live Pathfinder effect)} keep only the traced paths (drop the source image) → {ids}", has_selection_or_ids, trace_expand ), diff --git a/crates/engine/src/cmd/buildcmds/shape_builder.rs b/crates/engine/src/cmd/buildcmds/shape_builder.rs index 5cbcdea97..fea79a0e6 100644 --- a/crates/engine/src/cmd/buildcmds/shape_builder.rs +++ b/crates/engine/src/cmd/buildcmds/shape_builder.rs @@ -1,5 +1,5 @@ use super::{ - Beside, BuilderMap, EngineError, FillRule, Node, NodeId, PathData, Point, Rect, Result, Session, SubPath, Touched, Value, b, bad, check_numbers, + Arc, Beside, BuilderMap, EngineError, FillRule, Node, NodeId, PathData, Point, Rect, Result, Session, Touched, Value, b, bad, check_numbers, f64_or, fill_param, finite, gap_param, ids_json, json, marquee_param, po, points_param, remnant, replace_leaves, replace_with_pieces, root_ids, shape_node, switch_or, }; @@ -41,6 +41,7 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { let gap = gap_param(p); // The map the Shape Builder tool already has, which a click or drag of it is made over. let shared = s.tool.shape_builder_map().filter(|(_, g)| *g == gap); + let prepared = s.tool.shape_builder_merge(); let label = if erase { "Shape Builder Delete" } else { "Shape Builder" }; let (ids, merged, touched, split) = s.edit(label, |d, sel| { let (shapes, leaves) = b::shape_builder_shapes(d, &roots, open_filled_as_closed); @@ -89,16 +90,28 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { touched.add(map.hit(q, Some(tol))); return Ok((ids, None, touched, Some(split))); } - let mut touched = Touched { regions: marquee.map(|(a, b)| map.regions_in_marquee(a, b)).unwrap_or_default(), ..Default::default() }; - touched.extend(&map.builder_hits_along(&pts, piece_tol)); - touched.edges = map.erased_edges(&touched); + let touched = map.merge_touched(&pts, marquee, piece_tol); if touched.is_empty() { return Err(EngineError::Other("Shape Builder: no region under the pointer".into())); } - let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect(); + let inputs = b::merge_inputs(&leaves.iter().collect::>(), open_filled_as_closed); + // The tool computed this merge on another thread when it would come out the same. + let ready = prepared.as_ref().filter(|m| { + shared.as_ref().is_some_and(|(s, _)| Arc::ptr_eq(s, &m.map) && std::ptr::eq(s.as_ref(), map)) + && m.gap == gap + && m.touched == touched + && m.inputs == inputs + }); + let computed; + let geometry = match ready { + Some(m) => &m.geometry, + None => { + computed = b::merge_geometry(map, &touched, gap, &inputs); + &computed + } + }; let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect(); - let faces = po::FaceMerger::with_gap(&map.shapes, gap_param(p)); - let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) }; + let union = &geometry.union; // Pick Color From Artwork: the merged shape takes the look of the front-most object under the // drag's first point, else under its last point, else of the front-most object. Color // Swatches: the current fill, with the rest of the look of the front-most object over the @@ -115,7 +128,7 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { // What goes in place of each object the map was made from (none: it stays as it is). let mut outs: Vec>> = vec![]; let mut merged_id = None; - for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() { + for (i, leaf) in leaves.iter().take(map.shapes.len()).enumerate() { let mut out = None; let pieces: Vec = map.lines.iter().enumerate().filter(|(_, (l, _))| l.key == i as u64).map(|(k, _)| k).collect(); if !pieces.is_empty() { @@ -131,41 +144,15 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { } out = Some(kept); } - } else { + } else if let Some(Some(rest)) = geometry.rests.get(i) { // A shape loses the regions deleted or merged, then the edges deleted (each one on - // its outline, shared edges from every shape along them). - let in_union = picked.iter().any(|r| r.sources.contains(&i)); - let mut rest = if in_union { - // What is left of it: its faces not merged or deleted, united. They share their - // edges with the merged faces exactly, where subtracting the merged shape left - // slivers of the outline along them (#569). A sliver goes in only where the - // shape covers it and the merged shape does not take it in. - let kept: Vec<&po::Region> = map - .regions - .iter() - .enumerate() - .filter(|(k, (r, ..))| r.sources.contains(&i) && !touched.regions.contains(k)) - .map(|(_, (r, ..))| r) - .collect(); - if kept.is_empty() { PathData::default() } else { faces.merge_rest(i, &kept, &picked) } + // its outline, shared edges from every shape along them). What is left of its own + // outline keeps its fill rule; united faces fill with non-zero. + out = Some(if rest.path.is_empty() { + vec![] } else { - b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0) - }; - let fill_closes = open_filled_as_closed && !leaf.appearance.fill_paint().is_none(); - // Edges come back from the planar map within a few hundredths of the outlines they - // follow, whatever else is selected. - let cut_tol = b::outline_snap(&sh.path); - let mut cut = false; - for piece in &cuts { - if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) { - rest = r; - cut = true; - } - } - if in_union || cut { - // What is left of its own outline keeps its fill rule; united faces fill with non-zero. - out = Some(if rest.is_empty() { vec![] } else { vec![remnant(d, leaf, rest, in_union.then_some(FillRule::NonZero))] }); - } + vec![remnant(d, leaf, rest.path.clone(), rest.united.then_some(FillRule::NonZero))] + }); } // The merged shape goes in place of the object it takes its look from, so it stays in // that object's group (a clip group too). diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs index 00bcc1c83..9c6c28b90 100644 --- a/crates/engine/src/cmd/buildcmds/trace.rs +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -4,10 +4,16 @@ use vectorcraft_color::Swatch; use vectorcraft_doc::ImageObject; use super::{ - Appearance, AppearanceItem, Arc, Beside, Color, Document, EngineError, LineCap, LineJoin, LiveShape, Node, NodeId, NodeKind, Paint, Point, + Appearance, AppearanceItem, Arc, Beside, Color, Document, EngineError, LineCap, LineJoin, LiveShape, Node, NodeId, NodeKind, Paint, Point, Rect, Result, Session, StrokeLayer, TRACE_NAME, TraceView, Value, bad, fill_only, ids_json, json, root_ids, shape_node, str_param, switch_or, tr, }; +#[cfg(test)] +thread_local! { + static BOX_TESTS: std::cell::Cell = const { std::cell::Cell::new(0) }; + static CANDIDATE_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + // ---------- Image Trace ---------- fn is_trace_group(n: &Node) -> bool { @@ -16,6 +22,8 @@ fn is_trace_group(n: &Node) -> bool { && n.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_)))) } +const LIVE_PATHFINDER_BY_COLOR: &str = "byColor can't regroup a trace with a live Pathfinder effect: expand without byColor"; + /// The `view` param ([`TraceView`] id), if given. fn view_param(cmd: &str, p: &Value) -> Result> { let Some(v) = p.get("view").filter(|v| !v.is_null()) else { return Ok(None) }; @@ -368,6 +376,156 @@ fn grouped(d: &mut Document, nodes: Vec, parents: &[Option]) -> Vec roots.iter().filter_map(|&r| built.get_mut(r)?.take()).chain(ring).collect() } +/// The `byColor` param of an Expand (false when not given). +fn by_color_param(cmd: &str, p: &Value) -> Result { + match p.get("byColor").filter(|v| !v.is_null()) { + Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "byColor must be true or false")), + None => Ok(false), + } +} + +/// Expand by color: an expanded trace's `items` (bottom-most first) in a group for each paint, a +/// stroked line counted by its stroke. A solid color's group is named by the swatch it links to (a +/// tint with its percentage) or as `#rrggbb`, and gradient fills share one group, "Gradients". The +/// groups stack in the order of the lowest item in each, and each keeps its items' order. When the +/// paths meet, an item joins its paint's last group only if no item of a later group meets it +/// (by the boxes of everything they paint, effects included, within one point); otherwise it +/// starts another group of that paint, which keeps the stacking order of those items. Items +/// farther apart can still share antialiased pixels at small view or raster-export scales, so +/// their edge pixels may change. A group among `items` (Auto Grouping) is refused. +fn color_groups(cmd: &str, d: &mut Document, items: Vec>) -> Result>> { + use std::collections::{BTreeMap, HashMap}; + use std::ops::Bound::{Excluded, Unbounded}; + use vectorcraft_color::tint_percent; + // Grouped by the paint: unlinked (its #rrggbb), linked (the swatch and its exact tint), a + // gradient, or another paint (its label). The name is apart from it: a swatch may be named as + // a color's `#rrggbb` or as "Gradients". + type PaintKey = (u8, String, u32); + struct Run { + name: String, + members: Vec>, + } + // Boxes closer than a point count as overlapping: their antialiased edges can share pixels. + let meet = |a: &Rect, b: &Rect| { + #[cfg(test)] + BOX_TESTS.with(|n| n.set(n.get() + 1)); + let b = b.inflate(1.0, 1.0); + a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 + }; + // Choose a cell size from the painted extent and path count. A box over more than eight cells + // across but at most eight down (a stripe) is indexed by its rows, one the other way round by + // its columns, and one over more than eight both ways stays in a separate list, keeping the + // index bounded even when a path covers most of the document. + let items: Vec<_> = items + .into_iter() + .map(|item| { + let bounds = vectorcraft_render::painted_bounds(d, &item); + (item, bounds) + }) + .collect(); + let extent = items.iter().filter_map(|(_, b)| *b).reduce(|a, b| a.union(b)); + let cell_size = extent.map_or(1.0, |r| ((r.x1 - r.x0).abs() * (r.y1 - r.y0).abs() / items.len().max(1) as f64).sqrt().max(1.0)); + // The first and last column and row of the cells `r` covers; `None` when it isn't finite. + let span = |r: Rect| -> Option<(i64, i64, i64, i64)> { + let coord = |v: f64| (v / cell_size).floor() as i64; + [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()).then(|| (coord(r.x0), coord(r.y0), coord(r.x1), coord(r.y1))) + }; + let count = |first: i64, last: i64| i128::from(last) - i128::from(first) + 1; + // A box over more cells than this is checked against every box. + const MAX_CELLS: i128 = 1024; + let mut groups: Vec = vec![]; + let mut latest: HashMap = HashMap::new(); + // An item can join an earlier group, so insertion order is not group-index order. Index + // boxes by group to skip older groups without scanning their entries. + let mut spatial: HashMap<(i64, i64), BTreeMap>> = HashMap::new(); + let mut rows: HashMap>> = HashMap::new(); + let mut columns: HashMap>> = HashMap::new(); + let mut large: BTreeMap> = BTreeMap::new(); + let mut all_boxes: BTreeMap> = BTreeMap::new(); + for (item, bounds) in items { + if matches!(item.kind, NodeKind::Group { .. }) { + return Err(bad(cmd, "byColor can't split Auto Grouping groups: trace without Auto Grouping")); + } + let paint = match item.appearance.fill_paint() { + Paint::None => item.appearance.stroke_paint(), + fill => fill, + }; + let (key, name) = match &paint { + Paint::Solid { swatch: Some(n), tint, .. } => { + let percent = tint_percent(*tint); + let name = if *tint < 1.0 { format!("{n} {percent}%") } else { n.clone() }; + ((1, n.clone(), tint.to_bits()), name) + } + Paint::Solid { color, .. } => ((0, color.to_hex(), 0), color.to_hex()), + Paint::Gradient(_) => ((2, String::new(), 0), "Gradients".to_string()), + other => ((3, other.label(), 0), other.label()), + }; + // Joining an earlier group moves the item under every later group's items. + let hidden_by_later = |at: usize| { + bounds.is_some_and(|b| { + let overlaps_later = |boxes: &BTreeMap>| { + boxes.range((Excluded(at), Unbounded)).rev().any(|(_, group_boxes)| { + group_boxes.iter().rev().any(|box_bounds| { + #[cfg(test)] + CANDIDATE_VISITS.with(|n| n.set(n.get() + 1)); + meet(box_bounds, &b) + }) + }) + }; + match span(b) { + Some((x0, y0, x1, y1)) if count(x0, x1) * count(y0, y1) <= MAX_CELLS => { + (y0..=y1).flat_map(|y| (x0..=x1).map(move |x| (x, y))).filter_map(|c| spatial.get(&c)).any(overlaps_later) + || (y0..=y1).filter_map(|y| rows.get(&y)).any(overlaps_later) + || (x0..=x1).filter_map(|x| columns.get(&x)).any(overlaps_later) + || overlaps_later(&large) + } + _ => overlaps_later(&all_boxes), + } + }) + }; + let at = latest.get(&key).copied().filter(|&at| !hidden_by_later(at)); + let mut started = Run { name, members: vec![] }; + let joins = at.and_then(|at| groups.get_mut(at)); + let new = joins.is_none(); + let g = joins.unwrap_or(&mut started); + g.members.push(item); + if new { + groups.push(started); + latest.insert(key, groups.len() - 1); + } + if let Some(b) = bounds { + let index = if new { groups.len() - 1 } else { at.unwrap_or_default() }; + all_boxes.entry(index).or_default().push(b); + match span(b.inflate(1.0, 1.0)) { + Some((x0, y0, x1, y1)) if count(x0, x1) <= 8 && count(y0, y1) <= 8 => { + for place in (y0..=y1).flat_map(|y| (x0..=x1).map(move |x| (x, y))) { + spatial.entry(place).or_default().entry(index).or_default().push(b); + } + } + Some((_, y0, _, y1)) if count(y0, y1) <= 8 => { + for y in y0..=y1 { + rows.entry(y).or_default().entry(index).or_default().push(b); + } + } + Some((x0, _, x1, _)) if count(x0, x1) <= 8 => { + for x in x0..=x1 { + columns.entry(x).or_default().entry(index).or_default().push(b); + } + } + _ => large.entry(index).or_default().push(b), + } + } + } + Ok(groups + .into_iter() + .map(|run| { + let mut g = Node::group(d.alloc_id(), run.members); + g.name = Some(run.name); + Arc::new(g) + }) + .collect()) +} + /// What a trace depends on: the object traced (an image, or an Image Trace object traced again), /// its image, and the settings it is traced with. The tracer makes the same paths of the same /// inputs, so a trace made away from the command ([`TraceRun`]) stands in for the command's own. @@ -398,6 +556,10 @@ struct Prepared { fn prepare(s: &Session, c: &str, p: &Value) -> Result { let mut params = trace_params(s, c, p)?; let view = view_param(c, p)?; + let by_color = c == "imageTrace.makeAndExpand" && by_color_param(c, p)?; + if by_color && params.auto_grouping { + return Err(bad(c, "byColor can't split Auto Grouping groups: trace without Auto Grouping")); + } let params_json = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?; // The preset's name if the parameters are exactly that preset's or its enhanced version's (a // saved preset's under its saved name), else "Custom". @@ -428,6 +590,9 @@ fn prepare(s: &Session, c: &str, p: &Value) -> Result { }) .ok_or_else(|| bad(c, "select an image (or an Image Trace group)"))? }; + if by_color && s.doc()?.doc.node(target).is_some_and(vectorcraft_render::effects::has_pathfinder) { + return Err(bad(c, LIVE_PATHFINDER_BY_COLOR)); + } let blob = s.doc()?.doc.images.get(&image.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", image.key)))?; let bytes = Arc::clone(&blob.bytes); let library = trace_library(c, s, &mut params)?; @@ -533,6 +698,7 @@ impl TraceRun { pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { let c = if expand { "imageTrace.makeAndExpand" } else { "imageTrace.make" }; let Prepared { inputs, trace, view, params_json, preset_label, library, bytes } = prepare(s, c, p)?; + let group_by_color = expand && by_color_param(c, p)?; // A saved preset's settings join the journal entry, so a replay traces as this run did after // the preset is changed, renamed or deleted. let named = str_param(p, "preset").unwrap_or("Default"); @@ -546,7 +712,8 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result trace_now(&inputs, &trace, &bytes, &|_, _| true)?, }; let TraceInputs { target, image: img, .. } = inputs; - let warnings = trace_warnings((img.width, img.height), raster, res.worked_at); + let mut warnings = keep_warnings(&trace, &res); + warnings.extend(trace_warnings((img.width, img.height), raster, res.worked_at)); // Pixel space of the decoded raster → the image object's pixel space → document. let sx = img.width.max(1) as f64 / raster.0.max(1) as f64; let sy = img.height.max(1) as f64 / raster.1.max(1) as f64; @@ -608,6 +775,9 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result Vec { + if params.keep_colors.is_empty() { + return vec![]; + } + if params.mode == tr::Mode::Logo { + return vec!["Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds.".to_string()]; + } + if res.paths.is_empty() && res.keep_unmatched.len() == params.keep_colors.len() { + return vec![format!("Nothing was traced: none of the colors the trace found matches keepColors ({}).", params.keep_colors.join(", "))]; + } + let mut out = vec![]; + if res.paths.is_empty() && res.keep_ignored { + out.push("Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists.".to_string()); + } else if res.paths.is_empty() { + out.push("Nothing was traced: the colors that match keepColors produce no paths.".to_string()); + } + out.extend(res.keep_unmatched.iter().map(|c| format!("keepColors: none of the colors the trace found matches {c}, so nothing of it is traced."))); + out +} + fn trace_targets(s: &Session, p: &Value) -> Result> { let d = &s.doc()?.doc; let v: Vec = root_ids(s, p)?.into_iter().filter(|i| d.node(*i).is_some_and(is_trace_group)).collect(); @@ -706,7 +900,20 @@ pub(super) fn trace_release(s: &mut Session, p: &Value) -> Result { } pub(super) fn trace_expand(s: &mut Session, p: &Value) -> Result { + const C: &str = "imageTrace.expand"; + let by_color = by_color_param(C, p)?; let targets = trace_targets(s, p)?; + if by_color { + // A trace made with Auto Grouping is refused even when none of its paths were grouped. + let d = &s.doc()?.doc; + let grouped = |n: &Node| n.extras().trace.as_deref().and_then(|t| t.pointer("/params/autoGrouping")).and_then(Value::as_bool) == Some(true); + if targets.iter().any(|g| d.node(*g).is_some_and(grouped)) { + return Err(bad(C, "byColor can't split Auto Grouping groups: trace without Auto Grouping")); + } + if targets.iter().any(|g| d.node(*g).is_some_and(vectorcraft_render::effects::has_pathfinder)) { + return Err(bad(C, LIVE_PATHFINDER_BY_COLOR)); + } + } s.edit("Expand Image Trace", |d, sel| { for g in &targets { let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; @@ -715,6 +922,15 @@ pub(super) fn trace_expand(s: &mut Session, p: &Value) -> Result { if let Some(ch) = n.children_mut() { ch.retain(|c| !matches!(c.kind, NodeKind::Image(_))); } + if by_color { + let items = d.node(*g).and_then(Node::children).cloned().unwrap_or_default(); + // The paths may have been moved or reshaped since the trace was made, so any trace's + // paths are taken to overlap. + let groups = color_groups(C, d, items)?; + if let Some(ch) = d.node_mut(*g).and_then(Node::children_mut) { + *ch = groups; + } + } } sel.set(targets.iter().copied()); Ok(()) @@ -722,9 +938,181 @@ pub(super) fn trace_expand(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids_json(&targets) })) } +#[cfg(test)] +mod color_group_tests { + use super::*; + use vectorcraft_geom::{PathData, SubPath}; + + fn area(d: &mut Document, x0: f64, y0: f64, x1: f64, y1: f64, color: Color) -> Arc { + let path = PathData::single(SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)); + Arc::new(Node::path(d.alloc_id(), path, fill_only(Paint::solid(color)))) + } + + #[test] + fn abutting_color_groups_preserve_order_at_shared_edges() { + let mut d = Document::new(100.0, 100.0); + let a = Color::rgb(1.0, 0.0, 0.0); + let b = Color::rgb(0.0, 0.0, 1.0); + let items = vec![area(&mut d, 0.0, 0.0, 20.0, 20.0, a), area(&mut d, 20.0, 0.0, 40.0, 20.0, b), area(&mut d, 40.0, 0.0, 60.0, 20.0, a)]; + let original: Vec<_> = items.iter().map(|n| n.id).collect(); + let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let actual: Vec<_> = groups.iter().flat_map(|g| g.children().unwrap().iter().map(|n| n.id)).collect(); + assert_eq!(actual, original, "shared-edge paths changed stacking order"); + } + + #[test] + fn dense_color_groups_use_bounded_box_tests() { + let mut d = Document::new(200.0, 200.0); + let a = Color::rgb(1.0, 0.0, 0.0); + let b = Color::rgb(0.0, 0.0, 1.0); + let items = (0..4000) + .map(|i| { + if i % 2 == 0 { + let x = 100.0 + (i % 40) as f64 * 0.01; + area(&mut d, x, 100.0, x + 0.1, 100.1, a) + } else { + let x = if i % 4 == 1 { 0.0 } else { 198.0 }; + area(&mut d, x, x, x + 2.0, x + 2.0, b) + } + }) + .collect(); + BOX_TESTS.with(|n| n.set(0)); + let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let tests = BOX_TESTS.with(std::cell::Cell::get); + assert_eq!(groups.iter().map(|g| g.children().map_or(0, Vec::len)).sum::(), 4000); + assert!(tests < 4000 * 32, "{tests} box tests for 4000 paths"); + } + + #[test] + fn overlapping_alternating_colors_visit_bounded_candidates() { + const COUNT: usize = 3000; + let mut d = Document::new(100.0, 100.0); + let colors = [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)]; + let items = (0..COUNT).map(|i| area(&mut d, 10.0, 10.0, 20.0, 20.0, colors[i % 2])).collect(); + CANDIDATE_VISITS.with(|n| n.set(0)); + let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let visits = CANDIDATE_VISITS.with(std::cell::Cell::get); + assert_eq!(groups.len(), COUNT); + assert!(visits < COUNT * 16, "{visits} candidate visits for {COUNT} paths"); + } + + #[test] + fn separate_stripes_of_alternating_colors_visit_bounded_candidates() { + const COUNT: usize = 2000; + let mut d = Document::new(1000.0, 4000.0); + let colors = [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)]; + for tall in [false, true] { + let items = (0..COUNT) + .map(|i| { + let at = i as f64 * 2.0; + if tall { area(&mut d, at, 0.0, at + 0.5, 1000.0, colors[i % 2]) } else { area(&mut d, 0.0, at, 1000.0, at + 0.5, colors[i % 2]) } + }) + .collect(); + CANDIDATE_VISITS.with(|n| n.set(0)); + let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let visits = CANDIDATE_VISITS.with(std::cell::Cell::get); + assert_eq!(groups.len(), 2, "tall {tall}"); + assert!(visits < COUNT * 32, "tall {tall}: {visits} candidate visits for {COUNT} stripes"); + } + } + + #[test] + fn spatial_color_groups_match_simple_scan() { + let colors = [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0)]; + for layout in 0..6 { + let mut d = Document::new(200.0, 200.0); + let items: Vec<_> = (0..90) + .map(|i| { + let x = match layout { + 0 => i as f64 * 2.0, + 1 => (i % 10) as f64 * 5.0, + 2 => (i % 7) as f64 * 3.0, + 3 => (i % 13) as f64 * 9.0, + 4 => (i % 4) as f64 * 10.0, + _ => i as f64 * 1.5, + }; + let y = match layout { + 0 => 0.0, + 1 => (i / 10) as f64 * 5.0, + 2 => (i / 7) as f64 * 3.0, + 3 => (i / 13) as f64 * 9.0, + 4 => i as f64 * 1.5, + _ => (i % 4) as f64 * 10.0, + }; + // Layouts 4 and 5: wide and tall stripes, some meeting their neighbors. + let (w, h) = match layout { + 4 => (150.0, 1.0), + 5 => (1.0, 150.0), + _ => (3.0, 3.0), + }; + area(&mut d, x, y, x + w, y + h, colors[i % 3]) + }) + .collect(); + let mut expected: Vec<(String, Vec<(NodeId, Rect)>)> = vec![]; + for item in &items { + let key = item.appearance.fill_paint().color().unwrap().to_hex(); + let bounds = vectorcraft_render::painted_bounds(&d, item).unwrap(); + let previous = expected.iter().rposition(|(k, _)| *k == key); + let can_join = previous.filter(|&at| { + !expected.iter().skip(at + 1).any(|(_, boxes)| { + boxes.iter().any(|(_, m)| { + let b = bounds.inflate(1.0, 1.0); + m.x0 < b.x1 && b.x0 < m.x1 && m.y0 < b.y1 && b.y0 < m.y1 + }) + }) + }); + match can_join.and_then(|at| expected.get_mut(at)) { + Some((_, members)) => members.push((item.id, bounds)), + None => expected.push((key, vec![(item.id, bounds)])), + } + } + let actual = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let actual: Vec> = actual.iter().map(|g| g.children().unwrap().iter().map(|n| n.id).collect()).collect(); + let expected: Vec> = expected.into_iter().map(|(_, members)| members.into_iter().map(|(id, _)| id).collect()).collect(); + assert_eq!(actual, expected, "layout {layout}"); + } + } + + #[test] + fn an_abutting_gradient_stays_above_the_solid_area_it_borders() { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops.clone())))); + let mut d = Document::new(100.0, 100.0); + let mut area = |x0: f64, x1: f64, paint: Paint| { + let path = + PathData::single(SubPath::polyline(&[Point::new(x0, 0.0), Point::new(x1, 0.0), Point::new(x1, 60.0), Point::new(x0, 60.0)], true)); + Arc::new(Node::path(d.alloc_id(), path, fill_only(paint))) + }; + // A gradient, a solid area beside it and a second gradient beside that, bottom to top. + let items = vec![area(0.0, 50.0, ramp()), area(50.0, 80.0, Paint::solid(Color::rgb(0.2, 0.6, 0.2))), area(80.0, 100.0, ramp())]; + let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let names: Vec<_> = groups.iter().map(|g| g.name.clone().unwrap_or_default()).collect(); + assert_eq!(names, ["Gradients", "#339933", "Gradients"]); + } +} + #[cfg(test)] mod trace_limit_tests { - use super::{MAX_TRACE_ANCHORS, trace_warnings}; + use super::{MAX_TRACE_ANCHORS, keep_warnings, trace_warnings}; + use vectorcraft_trace as tr; + + #[test] + fn keep_warnings_name_matched_colors_that_produce_no_paths() { + let params = tr::TraceParams { keep_colors: vec!["#ff0000".into()], ..tr::TraceParams::default() }; + // A listed color was traced, and produced no paths: the palette lists no color without paths. + let traced = tr::TraceResult::default(); + let none = vec!["Nothing was traced: the colors that match keepColors produce no paths.".to_string()]; + assert_eq!(keep_warnings(¶ms, &traced), none); + // With Ignore Color, too: Ignore Color did not leave that color out. + let ignoring = tr::TraceParams { ignore_white: true, ..params.clone() }; + assert_eq!(keep_warnings(&ignoring, &traced), none); + // Ignore Color left out every color that matches: none was traced. + assert_eq!( + keep_warnings(&ignoring, &tr::TraceResult { keep_ignored: true, ..tr::TraceResult::default() }), + vec!["Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists.".to_string()] + ); + } #[test] fn trace_warnings_name_a_large_or_scaled_down_image() { diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index 1d6f2af3e..9463dd8cf 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -182,7 +182,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. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error", + "{dataBase64 (PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PCX, 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), centered on `center` (default: the first artboard) → {count, width, height (pt), pixelWidth, pixelHeight}; then run edit.paste. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error", has_doc, import_image ), diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index d70a81700..1d009a6b0 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -30,37 +30,37 @@ pub fn specs() -> Vec { "Invert Colors", ["Edit", "Edit Colors"], None, - "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {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), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {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) + |s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert) ), cmd!( "edit.colors.toCMYK", "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) + |s, p| recolor(s, p, "edit.colors.toCMYK", "Convert to CMYK", &to_cmyk) ), cmd!( "edit.colors.toGrayscale", "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) + |s, p| recolor(s, p, "edit.colors.toGrayscale", "Convert to Grayscale", &to_gray) ), cmd!( "edit.colors.toRGB", "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| { + recolor(s, p, "edit.colors.toRGB", "Convert to RGB", &|c| { let [r, g, b] = c.to_rgb(); Color::rgb(r, g, b) }) @@ -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,48 @@ 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, 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)> { + 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. A copy whose tile uses other copies counts when + /// those copies do. + fn link_only_copies(&self, d: &Document) -> HashMap { + 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(); + let pairs: Vec<(&String, &String, &PatternDef, &PatternDef)> = self + .patterns + .iter() + .filter_map(|(old, new)| { + let new = new.as_ref()?; + Some((new, old, d.pattern(old)?, *copies.get(new.as_str())?)) + }) + .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 /// pattern swatches, and return the number of paints, meshes and images changed. pub(crate) fn run(mut self, d: &mut Document, ids: &[NodeId]) -> usize { @@ -313,6 +355,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>, @@ -331,34 +396,123 @@ impl ColorVisitor for Visit<'_, '_> { } } -pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result { - run_pass(s, p, label, Recolor::new(Scope::of(p), f)) +pub(crate) fn recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result { + run_pass(s, p, cmd, label, Recolor::new(Scope::of(p), f)) } /// [`recolor`] with solid colours and gradient stops mapped through `links`, with their swatch /// link and tint. -pub(crate) fn recolor_links(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color, links: &LinkMap) -> Result { - run_pass(s, p, label, Recolor::with_links(Scope::of(p), f, links)) +pub(crate) fn recolor_links(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color, links: &LinkMap) -> Result { + run_pass(s, p, cmd, label, Recolor::with_links(Scope::of(p), f, links)) } -/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step. -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)?, +/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step named `label`; +/// errors name the command `cmd`. 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, cmd: &str, label: &str, pass: Recolor) -> Result { + let (ids, left) = match ids_param(p, "ids", cmd)? { + 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 colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); - let n = s.edit(label, |d, _| { + let (n, not_changed) = s.edit(label, |d, _| { + let not_changed = NotChanged::of(pass.again().would_change(d, &left).into_iter().map(|(_, hidden)| hidden)); let n = pass.run(d, &ids); - super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?; - Ok(n) + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?; + Ok((n, not_changed)) })?; - Ok(json!({ "changed": n })) + 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 { let i = (f64_req(p, "intensity", "edit.colors.saturate")?.clamp(-100.0, 100.0) / 100.0) as f32; - recolor(s, p, "Saturate", &move |c| { + recolor(s, p, "edit.colors.saturate", "Saturate", &move |c| { let [h, sat, v] = c.to_hsb(); let to = (sat * (1.0 + i)).clamp(0.0, 1.0); // A color whose saturation stays the same is returned as it is, so it keeps its swatch link. @@ -416,12 +570,12 @@ fn adjust_balance(s: &mut Session, p: &Value) -> Result { (*tint, *c) = (t, base.tinted(t)); true }; - return run_pass(s, p, "Adjust Colors", Recolor::links(Scope::of(p), &shift)); + return run_pass(s, p, C, "Adjust Colors", Recolor::links(Scope::of(p), &shift)); } other => return Err(bad(C, format!("unknown mode `{other}` (rgb|cmyk|gray|global)"))), }; let convert = bool_or(p, "convert", false); - recolor(s, p, "Adjust Colors", &move |c| { + recolor(s, p, C, "Adjust Colors", &move |c| { let out = adjust(c); if convert { out } else { keep_model(c, out) } }) diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 7bdcfad58..d9b5784b6 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -287,7 +287,8 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result { /// How Document Color Mode converts neutral RGB greys (R = G = B) to CMYK (the `grays` param). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Grays { - /// Through the CMYK profile like any colour: four-colour greys and a rich black. + /// Through the CMYK profile like any colour: light grays in cyan, magenta and yellow only, darker + /// grays in four inks, and a rich black. #[default] Profile, /// On the black plate only: K is the grey's ink percentage (as Convert to Grayscale and then diff --git a/crates/engine/src/cmd/compositefonts.rs b/crates/engine/src/cmd/compositefonts.rs new file mode 100644 index 000000000..3925612a1 --- /dev/null +++ b/crates/engine/src/cmd/compositefonts.rs @@ -0,0 +1,327 @@ +//! Type › Composite Fonts…: the document's composite fonts ([`vectorcraft_doc::CompositeFont`]), +//! which set each class of character (Kanji, Kana, Punctuation, Symbols, Roman, Numerals) in a +//! font of its own. Type names a composite font as its family, and the font menus list them first. +//! The font database holds the active document's composite fonts ([`sync`]); a file exported here +//! carries them to another document. +//! +//! Behavior reference (public documentation): +//! . + +use serde_json::{Map, Value, json}; +use vectorcraft_doc::text::{COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX}; +use vectorcraft_doc::{CharClass, CompositeFont, CompositePart, CompositeParts}; +use vectorcraft_text::FontDb; + +use super::fileio::{read_file_head, write_file}; +use super::textstyles::each_text_mut; +use super::typecmd::refresh_bounds; +use super::{CommandSpec, Result, Session, bad, cmd, has_doc, switch_or, text_param}; + +/// The extension of the files Export writes and Import reads. +pub const EXTS: &[&str] = &["vccomposite"]; + +/// The largest file Import reads, in bytes. +const MAX_FILE_BYTES: usize = 4 << 20; + +/// The keys a part may give. +const PART_KEYS: [&str; 7] = ["font", "style", "size", "baseline", "hScale", "vScale", "scaleAtCenter"]; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "type.compositeFonts.list", + "Composite Fonts", + [], + None, + "{} the active document's composite fonts → {fonts: [{name, parts: {kanji, kana, punctuation, symbols, roman, numerals: {font, style, size: % of the type size, baseline: % of the type size (positive raises), hScale: %, vScale: %, scaleAtCenter?: bool}}}]}", + has_doc, + list + ), + cmd!( + "type.compositeFonts.set", + "Composite Fonts…", + ["Type"], + None, + "{fonts: [{name, parts: {kanji: {font, style?: \"Regular\", size?: % 10..400 = 100, baseline?: % -100..100 = 0}, kana?, punctuation?, symbols?, roman?, numerals?: {font, style?, size?, baseline?, hScale?, vScale?: % 10..400 = 100, scaleAtCenter?: bool = false}}}]} replace the active document's composite fonts (Type › Composite Fonts…'s OK), as one undo step, none when nothing changes. A composite font sets each class of character in its part's font: kanji (the base: ideographs, full-width letters and digits and anything no other class takes), kana (hiragana and katakana, both widths), punctuation (East Asian and full-width punctuation, quotes and dashes), symbols (arrows, math, shapes and other symbols), roman (half-width letters, their punctuation and spaces) and numerals (the digits 0 to 9); a part left out is the kanji part. size and baseline are percent of the type's size, hScale and vScale scale kana, roman and numerals only, and scaleAtCenter (kana only) scales them from the em box's center instead of the baseline. Names are trimmed, unique in any case, at most 255 characters, and may not name an installed font; a part's font may not be a composite font; at most 256. Type set in a composite font names it as its family (text.setStyle {font: name}), and the font menus list composite fonts first. A font taken out stays named by its type, which then shows as missing → {fonts}", + has_doc, + set + ), + cmd!( + query "type.compositeFonts.export", + "Export Composite Fonts", + [], + None, + "{path?: .vccomposite file, names?: [name] (default: all), fonts?: [{name, parts}] (as type.compositeFonts.set takes them)} write the active document's composite fonts (all, or those named, any case), or the fonts given, to a file type.compositeFonts.import reads; without path the file's text comes back as data → {path, count} | {data, count}", + has_doc, + export + ), + cmd!( + "type.compositeFonts.import", + "Import Composite Fonts", + [], + None, + "{path: .vccomposite file | data: its text | dataBase64: its bytes (at most 4 MB), apply?: bool = true} read the composite fonts a file holds (type.compositeFonts.export writes them); with apply they join the active document's, a font of the same name (any case) replaced, as one undo step, none when nothing changes; apply false only reads them → {fonts, imported: [name]}", + has_doc, + import + ), + ] +} + +/// Register the active document's composite fonts in the font database, and remove the ones the +/// other open documents (or an earlier active document) defined. Run after each command and when +/// another document takes the front, so an undo, a revert, an opened or closed file or a document +/// switch draws type in the active document's own composite fonts, and type naming one it does +/// not define shows as missing. The lowercased names of the composite fonts added, changed or +/// removed. +pub(crate) fn sync(s: &mut Session) -> Vec { + let db = FontDb::global(); + let mut names = Vec::new(); + let mut changed = Vec::new(); + for f in s.active().map(|d| d.doc.composite_fonts.to_vec()).unwrap_or_default() { + let name = f.name.to_lowercase(); + if db.set_composite_font(&f) { + changed.push(name.clone()); + } + names.push(name); + } + let others = s.docs.iter().flat_map(|d| d.doc.composite_fonts.iter().map(|f| f.name.to_lowercase())); + let stale: Vec = s.composite_names.iter().cloned().chain(others).filter(|n| !names.contains(n)).collect(); + for gone in stale { + if db.remove_composite_font(&gone) { + changed.push(gone); + } + } + s.composite_names_registered.extend(names.iter().chain(&changed).cloned()); + s.composite_names = names; + changed +} + +/// The lowercased names of the composite fonts the open documents define, the font database holds +/// and the session has registered before: the names type may have been laid out in. +pub(crate) fn names_in_use(s: &Session) -> std::collections::HashSet { + let defined = s.docs.iter().flat_map(|d| d.doc.composite_fonts.iter().map(|f| f.name.to_lowercase())); + let held = FontDb::global().composite_font_names().into_iter().map(|n| n.to_lowercase()); + defined.chain(held).chain(s.composite_names_registered.iter().cloned()).collect() +} + +/// Leave out a file's unusable composite fonts before registering and measuring its type. +pub(crate) fn filter_loaded(doc: &mut vectorcraft_doc::Document) { + let db = FontDb::global(); + if let Some(kept) = CompositeFont::usable(&doc.composite_fonts) { + *doc.composite_fonts = kept; + } + doc.composite_fonts.retain(|f| !db.has_installed_family(&f.name)); +} + +fn list(s: &mut Session, _: &Value) -> Result { + Ok(json!({ "fonts": *s.doc()?.doc.composite_fonts })) +} + +/// Composite font `v` from command parameters or a file: its name trimmed, each part left out a +/// copy of the Kanji part, and keys or values a part can't take refused. +fn parse_font(v: &Value, c: &str) -> Result { + let o = v.as_object().ok_or_else(|| bad(c, "each font must be {name, parts}"))?; + if let Some(k) = o.keys().find(|k| !["name", "parts"].contains(&k.as_str())) { + return Err(bad(c, format!("unknown key `{k}`; a font takes name and parts"))); + } + let raw = o.get("name").and_then(Value::as_str).ok_or_else(|| bad(c, "each font needs a name"))?; + let name = CompositeFont::clean_name(raw) + .ok_or_else(|| bad(c, format!("`{raw}` can't name a composite font (1 to {COMPOSITE_NAME_MAX} characters, no control characters)")))?; + let parts = o.get("parts").and_then(Value::as_object).ok_or_else(|| bad(c, format!("`{name}`: parts must be an object")))?; + if let Some(k) = parts.keys().find(|k| !CharClass::ALL.iter().any(|c| c.key() == k.as_str())) { + return Err(bad(c, format!("`{name}`: unknown class `{k}`; the classes are kanji, kana, punctuation, symbols, roman and numerals"))); + } + let part = |class: CharClass| -> Result> { + let Some(p) = parts.get(class.key()) else { return Ok(None) }; + let po = p.as_object().ok_or_else(|| bad(c, format!("`{name}`: {} must be an object", class.key())))?; + if let Some(k) = po.keys().find(|k| !PART_KEYS.contains(&k.as_str())) { + return Err(bad(c, format!("`{name}`: unknown key `{k}` in {}", class.key()))); + } + serde_json::from_value(p.clone()).map(Some).map_err(|e| bad(c, format!("`{name}`: {}: {e}", class.key()))) + }; + let kanji = part(CharClass::Kanji)?.ok_or_else(|| bad(c, format!("`{name}`: give the kanji part")))?; + let mut f = CompositeFont { name: name.clone(), parts: CompositeParts::all(kanji) }; + for class in CharClass::ALL.into_iter().skip(1) { + if let Some(p) = part(class)? { + *f.parts.get_mut(class) = p; + } + } + f.check().map_err(|e| bad(c, format!("`{}`: {e}", f.name)))?; + Ok(f) +} + +/// Error unless `fonts` can stand together in a document: names unique in any case, none naming +/// an installed font, no part set in a composite font, and at most [`COMPOSITE_FONTS_MAX`]. +fn check_set(fonts: &[CompositeFont], c: &str) -> Result<()> { + if fonts.len() > COMPOSITE_FONTS_MAX { + return Err(bad(c, format!("a document keeps at most {COMPOSITE_FONTS_MAX} composite fonts"))); + } + let db = FontDb::global(); + let mut names = std::collections::HashSet::new(); + for f in fonts { + if !names.insert(f.name.to_lowercase()) { + return Err(bad(c, format!("two composite fonts are named `{}`", f.name))); + } + if db.has_installed_family(&f.name) { + return Err(bad(c, format!("`{}` is the name of an installed font", f.name))); + } + } + for f in fonts { + for class in CharClass::ALL { + let font = &f.parts.get(class).font; + if names.contains(&font.to_lowercase()) || db.composite_font(font).is_some() { + return Err(bad(c, format!("`{}`: {} is set in `{font}`, a composite font", f.name, class.key()))); + } + } + } + Ok(()) +} + +/// Make `after` the active document's composite fonts, as one undo step named `label` (none when +/// they are what it has). The font database forgets the ones taken out, and type set in a font +/// that changed is measured again. +fn replace(s: &mut Session, label: &str, after: Vec) -> Result<()> { + let before = s.doc()?.doc.composite_fonts.to_vec(); + if before == after { + return Ok(()); + } + let changed: Vec = + before.iter().filter(|b| !after.contains(b)).chain(after.iter().filter(|a| !before.contains(a))).map(|f| f.name.to_lowercase()).collect(); + // A refused or panicked edit leaves the document without the new fonts: `sync` forgets a name + // listed here that the document doesn't define. + s.composite_names.extend(after.iter().map(|f| f.name.to_lowercase())); + let db = FontDb::global(); + for f in &before { + if !after.iter().any(|a| CompositeFont::same_name(&a.name, &f.name)) { + db.remove_composite_font(&f.name); + } + } + for f in &after { + db.set_composite_font(f); + } + let r = s.edit(label, |d, _| { + *d.composite_fonts = after.clone(); + each_text_mut(d, &mut |t| { + if t.runs.iter().any(|r| changed.contains(&r.style.font_family.to_lowercase())) { + refresh_bounds(t); + } + Ok(()) + }) + }); + sync(s); + r +} + +fn set(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.compositeFonts.set"; + let list = p.get("fonts").and_then(Value::as_array).ok_or_else(|| bad(C, "give fonts: [{name, parts}…]"))?; + let fonts = list.iter().map(|v| parse_font(v, C)).collect::>>()?; + check_set(&fonts, C)?; + replace(s, "Composite Fonts", fonts.clone())?; + Ok(json!({ "fonts": fonts })) +} + +fn export(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.compositeFonts.export"; + let path = text_param(p, "path", C)?; + if path.is_some_and(|p| p.trim().is_empty()) { + return Err(bad(C, "path is empty")); + } + let all = match p.get("fonts") { + None | Some(Value::Null) => s.doc()?.doc.composite_fonts.to_vec(), + Some(Value::Array(list)) => { + let fonts = list.iter().map(|v| parse_font(v, C)).collect::>>()?; + check_set(&fonts, C)?; + fonts + } + Some(_) => return Err(bad(C, "fonts must be a list of composite fonts")), + }; + let fonts = match p.get("names") { + None | Some(Value::Null) => all, + Some(Value::Array(names)) => { + let mut out = Vec::with_capacity(names.len()); + for n in names { + let n = n.as_str().ok_or_else(|| bad(C, "names must be strings"))?; + let f = all + .iter() + .find(|f| CompositeFont::same_name(&f.name, n.trim())) + .ok_or_else(|| bad(C, format!("no composite font named `{n}`")))?; + if !out.contains(f) { + out.push(f.clone()); + } + } + out + } + Some(_) => return Err(bad(C, "names must be a list of names")), + }; + if fonts.is_empty() { + return Err(bad(C, "no composite fonts to export")); + } + let mut file = Map::new(); + file.insert("compositeFonts".into(), json!(fonts)); + let text = serde_json::to_string_pretty(&file).map_err(|e| bad(C, e.to_string()))?; + match path { + Some(path) => { + write_file(path, text.as_bytes())?; + Ok(json!({ "path": path, "count": fonts.len() })) + } + None => Ok(json!({ "data": text, "count": fonts.len() })), + } +} + +/// The composite fonts in the bytes of an exported file. +fn read_fonts(bytes: &[u8], c: &str) -> Result> { + let v: Value = serde_json::from_slice(bytes).map_err(|e| bad(c, format!("not a composite fonts file: {e}")))?; + let list = v.get("compositeFonts").and_then(Value::as_array).ok_or_else(|| bad(c, "not a composite fonts file: no compositeFonts list"))?; + if list.len() > COMPOSITE_FONTS_MAX { + return Err(bad(c, format!("the file holds more than {COMPOSITE_FONTS_MAX} composite fonts"))); + } + list.iter().map(|f| parse_font(f, c)).collect() +} + +fn import(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.compositeFonts.import"; + let apply = switch_or(p, "apply", true, C)?; + let given = (text_param(p, "path", C)?, text_param(p, "data", C)?, text_param(p, "dataBase64", C)?); + let too_large = || bad(C, "the file is larger than 4 MB"); + let bytes = match given { + (Some(path), None, None) if !path.trim().is_empty() => read_file_head(path, MAX_FILE_BYTES + 1)?, + (None, Some(text), None) => text.as_bytes().to_vec(), + (None, None, Some(b64)) => { + if b64.len() > MAX_FILE_BYTES / 3 * 4 + 4 { + return Err(too_large()); + } + vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "dataBase64 is not base64"))? + } + _ => return Err(bad(C, "give one of path, data or dataBase64")), + }; + if bytes.len() > MAX_FILE_BYTES { + return Err(too_large()); + } + let fonts = read_fonts(&bytes, C)?; + if fonts.is_empty() { + return Err(bad(C, "the file holds no composite fonts")); + } + let mut names = std::collections::HashSet::new(); + if let Some(f) = fonts.iter().find(|f| !names.insert(f.name.to_lowercase())) { + return Err(bad(C, format!("the file holds two composite fonts named `{}`", f.name))); + } + if apply { + let mut after = s.doc()?.doc.composite_fonts.to_vec(); + for f in &fonts { + match after.iter_mut().find(|a| CompositeFont::same_name(&a.name, &f.name)) { + Some(a) => *a = f.clone(), + None => after.push(f.clone()), + } + } + check_set(&after, C)?; + replace(s, "Import Composite Fonts", after)?; + } else { + check_set(&fonts, C)?; + } + let imported: Vec<&str> = fonts.iter().map(|f| f.name.as_str()).collect(); + Ok(json!({ "fonts": fonts, "imported": imported })) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs new file mode 100644 index 000000000..f51f066df --- /dev/null +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -0,0 +1,646 @@ +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind}; + +use crate::Session; + +/// A composite font named `name`: Source Sans 3 for the base, JetBrains Mono at half size for +/// Roman, Inter for numerals. +fn font(name: &str) -> Value { + json!({"name": name, "parts": { + "kanji": {"font": "Source Sans 3"}, + "roman": {"font": "JetBrains Mono", "size": 50, "baseline": 10}, + "numerals": {"font": "Inter", "hScale": 80}, + }}) +} + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn names(s: &Session) -> Vec { + s.doc().unwrap().doc.composite_fonts.iter().map(|f| f.name.clone()).collect() +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn text(s: &Session, id: u64) -> vectorcraft_doc::TextObject { + match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +/// A scratch folder of the test's own. +fn folder(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("vectorcraft-composite-fonts-{name}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Composite Fonts' OK replaces the document's composite fonts as one undo step (none when nothing +/// changes); parts left out copy the Kanji part; they are saved with the document and undone with +/// it. +#[test] +fn composite_fonts_are_set_as_one_undo_step_and_saved() { + let mut s = session(); + let before = steps(&s); + let r = s.execute("type.compositeFonts.set", &json!({"fonts": [font(" CF Saved A "), font("CF Saved B")]})).unwrap(); + assert_eq!(r["fonts"][0]["name"], "CF Saved A"); + assert_eq!(names(&s), ["CF Saved A", "CF Saved B"]); + assert_eq!(steps(&s), before + 1); + let f = &s.doc().unwrap().doc.composite_fonts[0]; + assert_eq!((f.parts.kana.font.as_str(), f.parts.roman.size, f.parts.numerals.h_scale), ("Source Sans 3", 50.0, 80.0)); + // The same fonts again change nothing. + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Saved A"), font("CF Saved B")]})).unwrap(); + assert_eq!(steps(&s), before + 1); + let listed = s.execute("type.compositeFonts.list", &json!({})).unwrap(); + assert_eq!(listed["fonts"].as_array().map(Vec::len), Some(2)); + // They travel with the file. + let back = vectorcraft_format::load(&vectorcraft_format::save_file(&s.doc().unwrap().doc)).unwrap(); + assert_eq!(*back.composite_fonts, *s.doc().unwrap().doc.composite_fonts); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(names(&s).is_empty()); +} + +/// Bad fonts are refused with an error and no undo step: a name that is empty, shared (in any +/// case) or an installed font's; a part set in a composite font; an unknown class or key; a value +/// out of range, of the wrong type, or on a class that can't take it; no Kanji part. +#[test] +fn bad_composite_fonts_are_refused() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Refused Base")]})).unwrap(); + let before = steps(&s); + let with = |f: &dyn Fn(&mut Value)| { + let mut v = font("CF Refused"); + f(&mut v); + json!({"fonts": [v]}) + }; + for bad in [ + json!({}), + json!({"fonts": "CF"}), + json!({"fonts": [font(" ")]}), + json!({"fonts": [font("CF Twice"), font("cf twice")]}), + json!({"fonts": [font("Source Sans 3")]}), + json!({"fonts": [font("Tab\tName")]}), + with(&|v| v["parts"]["roman"]["font"] = json!("CF Refused Base")), + with(&|v| v["parts"]["roman"]["font"] = json!("CF Refused")), + with(&|v| v["parts"]["hangul"] = json!({"font": "Inter"})), + with(&|v| v["parts"]["Roman"] = json!({"font": "Inter"})), + with(&|v| v["parts"]["roman"]["width"] = json!(3)), + with(&|v| v["parts"]["roman"]["size"] = json!(5)), + with(&|v| v["parts"]["roman"]["size"] = json!("50")), + with(&|v| v["parts"]["roman"]["baseline"] = json!(-120)), + with(&|v| v["parts"]["kanji"]["hScale"] = json!(90)), + with(&|v| v["parts"]["punctuation"] = json!({"font": "Inter", "vScale": 90})), + with(&|v| v["parts"]["roman"]["scaleAtCenter"] = json!(true)), + with(&|v| v["parts"]["kana"] = json!({"font": "Inter", "scaleAtCenter": "yes"})), + with(&|v| v["parts"]["roman"]["font"] = json!("")), + with(&|v| v["extra"] = json!(1)), + with(&|v| { + v["parts"].as_object_mut().unwrap().remove("kanji"); + }), + ] { + assert!(s.execute("type.compositeFonts.set", &bad).is_err(), "accepted {bad}"); + assert_eq!(names(&s), ["CF Refused Base"]); + assert_eq!(steps(&s), before); + } + // Kana scaled at the center is allowed. + let ok = with(&|v| v["parts"]["kana"] = json!({"font": "Inter", "vScale": 80, "scaleAtCenter": true})); + s.execute("type.compositeFonts.set", &ok).unwrap(); + assert_eq!(names(&s), ["CF Refused"]); +} + +/// Type set in a composite font names it and is not missing; each class is drawn in its part's +/// font; changing the font measures the type again, and taking it out leaves the type naming it, +/// shown as missing. +#[test] +fn type_set_in_a_composite_font_follows_it() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Type")]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("text.setStyle", &json!({"ids": [id], "font": "CF Type"})).unwrap(); + assert!(r.get("warning").is_none(), "{r}"); + let t = text(&s, id); + assert_eq!(t.runs[0].style.font_family, "CF Type"); + let db = vectorcraft_text::FontDb::global(); + let face = |family: &str| db.face(family, "Regular").unwrap().id(); + let lay = vectorcraft_text::layout(db, &t); + let fonts: Vec = lay.glyphs.iter().map(|g| g.font_id).collect(); + assert_eq!(fonts, [face("JetBrains Mono"), face("JetBrains Mono"), face("Inter"), face("Inter")]); + let missing = |s: &mut Session| s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().filter(|f| f["missing"] == true).count(); + assert_eq!(missing(&mut s), 0); + // Roman at full size makes the type wider, measured again in the same step. + let wide = t.cached_bounds.unwrap().width(); + let mut full = font("CF Type"); + full["parts"]["roman"]["size"] = json!(100); + let before = steps(&s); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + assert_eq!(steps(&s), before + 1); + assert!(text(&s, id).cached_bounds.unwrap().width() > wide + 1.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert!((text(&s, id).cached_bounds.unwrap().width() - wide).abs() < 1e-6); + assert_eq!(db.composite_font("CF Type").unwrap().parts.roman.size, 50.0); + // Taken out, the type keeps the name, now missing; undone, the font is back. + s.execute("type.compositeFonts.set", &json!({"fonts": []})).unwrap(); + assert!(db.composite_font("CF Type").is_none()); + assert_eq!(text(&s, id).runs[0].style.font_family, "CF Type"); + assert_eq!(missing(&mut s), 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(db.composite_font("CF Type").is_some()); + assert_eq!(missing(&mut s), 0); +} + +/// Type in a symbol's art is measured again too when the composite font it names changes. +#[test] +fn type_in_symbol_art_follows_its_composite_font() { + fn width(s: &Session) -> f64 { + fn find(n: &vectorcraft_doc::Node) -> Option { + if let NodeKind::Text(t) = &n.kind { + return t.cached_bounds.map(|b| b.width()); + } + n.children().into_iter().flatten().find_map(|c| find(c)) + } + s.doc().unwrap().doc.symbols.iter().find_map(|sym| find(&sym.art)).unwrap() + } + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Symbol")]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Symbol"})).unwrap()["id"].as_u64().unwrap(); + s.execute("symbol.new", &json!({"name": "CF Mark", "ids": [id]})).unwrap(); + let narrow = width(&s); + let mut full = font("CF Symbol"); + full["parts"]["roman"]["size"] = json!(100); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + assert!(width(&s) > narrow + 1.0); +} + +/// The font database forgets a composite font once no open document defines it: after the step +/// that added it is undone, and after its document closes. +#[test] +fn composite_fonts_no_document_defines_are_forgotten() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Forgotten Undo")]})).unwrap(); + assert!(db.composite_font("CF Forgotten Undo").is_some()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(db.composite_font("CF Forgotten Undo").is_none()); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(db.composite_font("CF Forgotten Undo").is_some()); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Forgotten Close")]})).unwrap(); + s.execute("file.close", &json!({})).unwrap(); + assert!(db.composite_font("CF Forgotten Close").is_none()); + assert!(db.composite_font("CF Forgotten Undo").is_some()); +} + +/// SVG and legacy `.ai` write type in a composite font as its outlines, since a reader can't find +/// the font by its name. +#[test] +fn exports_outline_type_in_a_composite_font() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Export")]})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab1", "size": 20, "font": "CF Export"})).unwrap(); + 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("CF Export") && !svg.contains(">(); + let svg = vectorcraft_svg::export(&doc, &vectorcraft_svg::ExportOptions::default()); + assert!(!svg.contains("CF Standalone") && !svg.contains(" = missing["fonts"].as_array().unwrap().iter().filter_map(|f| f["family"].as_str()).collect(); + assert_eq!(names, ["No Such Part Font"], "{missing}"); + let fonts = s.execute("text.fonts", &json!({})).unwrap(); + let row = fonts.as_array().unwrap().iter().find(|r| r["family"] == "CF Inventory").cloned().unwrap(); + assert_eq!((&row["missing"], &row["status"]), (&json!(true), &json!("missing")), "{row}"); + assert_eq!(row["resolved"]["family"], vectorcraft_text::FALLBACK_FAMILY, "{row}"); + // A run that starts with a combining mark after a run ending in kana: the mark is Roman. + let boundary = json!({"name": "CF Boundary", "parts": {"kanji": {"font": "Source Sans 3"}, "roman": {"font": "No Such Part Font"}}}); + s.execute("type.compositeFonts.set", &json!({"fonts": [boundary]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 170, "text": "\u{304B}\u{301}", "size": 20, "font": "CF Boundary"})).unwrap()["id"] + .as_u64() + .unwrap(); + let row = + |s: &mut Session| s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().find(|r| r["family"] == "CF Boundary").cloned(); + assert_eq!(row(&mut s).unwrap()["status"], "exact"); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 5, "size": 30})).unwrap(); + assert_eq!(text(&s, id).runs.len(), 2); + assert_eq!(row(&mut s).unwrap()["status"], "missing"); + // A symbol the Kanji part's font draws but the Symbols part's font lacks. + let db = vectorcraft_text::FontDb::global(); + let (mono, sans) = (db.face("JetBrains Mono", "Regular").unwrap(), db.face("Source Sans 3", "Regular").unwrap()); + let c = ('\u{2190}'..='\u{2BFF}').find(|c| mono.covers(*c) && !sans.covers(*c)).expect("a symbol only JetBrains Mono draws"); + let glyphs = json!({"name": "CF Glyphs", "parts": {"kanji": {"font": "JetBrains Mono"}, "symbols": {"font": "Source Sans 3"}}}); + s.execute("type.compositeFonts.set", &json!({"fonts": [glyphs]})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 130, "text": c.to_string(), "size": 20, "font": "CF Glyphs"})).unwrap(); + let fonts = s.execute("text.fonts", &json!({})).unwrap(); + let row = fonts.as_array().unwrap().iter().find(|r| r["family"] == "CF Glyphs").cloned().unwrap(); + assert_eq!(row["missingGlyphs"], 1, "{row}"); +} + +/// The Glyphs panel's alternates of a character in a composite font come from the font of the +/// part its grapheme cluster falls in. +#[test] +fn glyph_alternates_of_composite_type_come_from_the_characters_part() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Glyph Part")]})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Glyph Part"})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("text.glyphAlternates", &json!({"id": id, "start": 2})).unwrap(); + assert_eq!(r["font"], "Inter", "{r}"); + let r = s.execute("text.glyphAlternates", &json!({"id": id, "start": 0})).unwrap(); + assert_eq!(r["font"], "JetBrains Mono", "{r}"); + // A combining mark is drawn in the part of the character it follows. + let id = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "1\u{301}", "size": 20, "font": "CF Glyph Part"})).unwrap()["id"] + .as_u64() + .unwrap(); + let r = s.execute("text.glyphAlternates", &json!({"id": id, "start": 1})).unwrap(); + assert_eq!(r["font"], "Inter", "{r}"); +} + +/// With two documents defining one name differently, the type is drawn in the front document's. +#[test] +fn the_front_document_defines_a_shared_name() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Front")]})).unwrap(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let mut other = font("CF Front"); + other["parts"]["roman"]["size"] = json!(75); + s.execute("type.compositeFonts.set", &json!({"fonts": [other]})).unwrap(); + let db = vectorcraft_text::FontDb::global(); + assert_eq!(db.composite_font("CF Front").unwrap().parts.roman.size, 75.0); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(db.composite_font("CF Front").unwrap().parts.roman.size, 50.0); +} + +/// A composite font only a document in the back defines is not available in the front one, where +/// type naming it shows as missing; a click on the back document's tab brings it back. +#[test] +fn a_font_only_a_back_document_defines_is_missing_in_front() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Back Only")]})).unwrap(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + assert!(db.composite_font("CF Back Only").is_none()); + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "CF Back Only"})).unwrap(); + let fonts = s.execute("text.fonts", &json!({})).unwrap(); + assert!(fonts.as_array().unwrap().iter().any(|f| f["missing"] == true), "{fonts}"); + assert!(s.set_active(0)); + assert!(db.composite_font("CF Back Only").is_some()); +} + +/// A newly opened document measures a missing font without using the previous tab's composite +/// font of the same name. +#[test] +fn opened_document_measures_missing_composite_font_as_missing() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Previous Tab")]})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "CF Previous Tab"})).unwrap()["id"].as_u64().unwrap(); + let mut doc = (*s.doc().unwrap().doc).clone(); + doc.composite_fonts.clear(); + if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind { + t.cached_bounds = None; + } + s.add_document(doc, None); + assert!(s.set_active(0)); + assert!(s.set_active(1)); + let actual = text(&s, id); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// The native file of a document whose composite font `name` sets Roman at full size, with type +/// "Ab12" set in it → (the file, the type's id, its width). +fn full_roman_file(name: &str) -> (Vec, u64, f64) { + let mut s = session(); + let mut full = font(name); + full["parts"]["roman"]["size"] = json!(100); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": name})).unwrap()["id"].as_u64().unwrap(); + let width = text(&s, id).cached_bounds.unwrap().width(); + (vectorcraft_format::save_file(&s.doc().unwrap().doc), id, width) +} + +/// Type in a missing font that no composite font is named after keeps the bounds it was measured +/// with when its file was read: opening the document does not lay it out again. +#[test] +fn opening_leaves_type_in_an_unrelated_missing_font_as_measured() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Unrelated")]})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "No Such Family CF"})).unwrap()["id"].as_u64().unwrap(); + let mut doc = (*s.doc().unwrap().doc).clone(); + let kept = vectorcraft_geom::Rect::new(1.0, 2.0, 3.0, 4.0); + if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind { + t.cached_bounds = Some(kept); + } + s.add_document(doc, None); + assert_eq!(text(&s, id).cached_bounds, Some(kept)); +} + +/// Text holding an inline graphic whose symbol's art is type in a composite font is measured with +/// the graphic at the size the opened document's own definition gives it. +#[test] +fn opening_measures_inline_graphics_of_composite_type_in_its_own_definition() { + let mut s = session(); + let mut full = font("CF Inline"); + full["parts"]["roman"]["size"] = json!(100); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + let art = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Inline"})).unwrap()["id"].as_u64().unwrap(); + s.execute("symbol.new", &json!({"name": "CF Inline Mark", "ids": [art]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "x", "size": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.insertInline", &json!({"id": id, "at": 0, "symbol": "CF Inline Mark"})).unwrap(); + let width = text(&s, id).cached_bounds.unwrap().width(); + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Inline")]})).unwrap(); + let file = crate::cmd::fileio::read_for_open("inline.vectorcraft", &bytes, None, &json!({}), false, false).unwrap(); + crate::cmd::fileio::open_read(&mut s, file).unwrap(); + assert!((text(&s, id).cached_bounds.unwrap().width() - width).abs() < 1e-6); +} + +/// Type read for opening while a composite font of its name was registered is measured again when +/// it opens, even after the document that defined the font has closed in between. +#[test] +fn opening_measures_type_read_in_a_composite_font_closed_since() { + let mut s = session(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Gone"})).unwrap()["id"].as_u64().unwrap(); + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Gone")]})).unwrap(); + let file = crate::cmd::fileio::read_for_open("gone.vectorcraft", &bytes, None, &json!({}), false, false).unwrap(); + assert!(s.close_document(1)); + crate::cmd::fileio::open_read(&mut s, file).unwrap(); + let actual = text(&s, id); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A file read for opening lays out its type in the composite fonts registered then (the front +/// document's, which defines the name with Roman at half size); once the file opens, its type is +/// measured again in its own. +#[test] +fn an_opened_file_measures_its_type_in_its_own_composite_fonts() { + let (bytes, id, width) = full_roman_file("CF Opened"); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Opened")]})).unwrap(); + let file = crate::cmd::fileio::read_for_open("opened.vectorcraft", &bytes, None, &json!({}), false, false).unwrap(); + crate::cmd::fileio::open_read(&mut s, file).unwrap(); + let actual = text(&s, id); + assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A document replaced while in the back (File › Revert) is measured in its own composite fonts +/// when it comes to the front, not in the front document's. +#[test] +fn a_document_replaced_in_the_back_is_measured_in_its_own_composite_fonts() { + let (bytes, id, width) = full_roman_file("CF Replaced"); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Replaced")]})).unwrap(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Replaced")]})).unwrap(); + assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap())); + assert!(s.set_active(0)); + let actual = text(&s, id); + assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// So is one that comes to the front because the front document closes. +#[test] +fn a_document_replaced_in_the_back_is_measured_when_the_front_one_closes() { + let (bytes, id, width) = full_roman_file("CF Closed"); + let mut s = session(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Closed")]})).unwrap(); + assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap())); + assert!(s.close_document(1)); + let actual = text(&s, id); + assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A failed batch takes back the composite fonts it set, in the font database too. +#[test] +fn a_failed_batch_takes_back_its_composite_fonts() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + let steps = json!([ + {"command": "type.compositeFonts.set", "params": {"fonts": [font("CF Failed Batch")]}}, + {"command": "no.such.command"}, + ]); + assert!(s.execute("command.batch", &json!({"commands": steps})).is_err()); + assert!(names(&s).is_empty()); + assert!(db.composite_font("CF Failed Batch").is_none()); +} + +/// A composite font whose edit is refused, because it makes selected type too wide for the canvas, +/// is not left in the font database. +#[test] +fn a_refused_edit_takes_back_its_new_composite_font() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + let x = crate::MAX_COORD - 1000.0; + s.execute("text.create", &json!({"x": x, "y": 50, "text": "MMMMMMMM", "size": 100, "font": "CF Refused Edit"})).unwrap(); + let wide = json!({"name": "CF Refused Edit", "parts": {"kanji": {"font": "Source Sans 3", "size": 400}}}); + let e = s.execute("type.compositeFonts.set", &json!({"fonts": [wide]})).unwrap_err().to_string(); + assert!(e.contains("exceed the canvas"), "{e}"); + assert!(names(&s).is_empty()); + assert!(db.composite_font("CF Refused Edit").is_none()); +} + +/// A loaded composite font cannot replace an installed family used by existing type. +#[test] +fn loaded_composite_font_cannot_take_an_installed_family_name() { + let mut s = session(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + let mut doc = (*s.doc().unwrap().doc).clone(); + doc.composite_fonts.push(vectorcraft_doc::CompositeFont::new("Inter", "Source Sans 3", "Regular")); + if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind { + t.cached_bounds = None; + } + s.add_document(doc, None); + assert!(names(&s).is_empty()); + assert!(vectorcraft_text::FontDb::global().composite_font("Inter").is_none()); + let actual = text(&s, id); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A file's composite fonts that Composite Fonts… would refuse (a value out of range, a name used +/// twice) are left out when the file opens. +#[test] +fn unusable_composite_fonts_in_a_file_are_left_out() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + let mut doc = (*s.doc().unwrap().doc).clone(); + let mut wild = vectorcraft_doc::CompositeFont::new("CF Wild", "Source Sans 3", "Regular"); + wild.parts.roman.size = 5000.0; + let good = vectorcraft_doc::CompositeFont::new("CF Kept", "Source Sans 3", "Regular"); + let twice = vectorcraft_doc::CompositeFont::new("cf kept", "Inter", "Regular"); + *doc.composite_fonts = vec![wild, good, twice]; + s.add_document(doc, None); + assert_eq!(names(&s), ["CF Kept"]); + s.execute("document.activate", &json!({"index": 1})).unwrap(); + assert!(db.composite_font("CF Wild").is_none()); + assert_eq!(db.composite_font("CF Kept").unwrap().parts.roman.font, "Source Sans 3"); +} + +/// Export writes the document's composite fonts (all, or those named) to a file; Import reads it: +/// with apply false it only returns them, otherwise they join the document's, replacing a font of +/// the same name, as one undo step. +#[test] +fn composite_fonts_export_and_import() { + let dir = folder("io"); + let path = dir.join("Mixed fonts (日本).vccomposite"); + let path = path.to_str().unwrap(); + let mut s = session(); + assert!(s.execute("type.compositeFonts.export", &json!({"path": path})).is_err(), "nothing to export"); + let mut slash = font("CF IO a/b: \"quoted\" 日本"); + slash["parts"]["kana"] = json!({"font": "Inter", "vScale": 80, "scaleAtCenter": true}); + s.execute("type.compositeFonts.set", &json!({"fonts": [slash, font("CF IO Other")]})).unwrap(); + assert!(s.execute("type.compositeFonts.export", &json!({"path": path, "names": ["CF IO Nope"]})).is_err()); + let r = s + .execute("type.compositeFonts.export", &json!({"path": path, "names": ["cf io a/b: \"quoted\" 日本", "CF IO a/b: \"quoted\" 日本"]})) + .unwrap(); + assert_eq!(r["count"], 1); + let saved = s.doc().unwrap().doc.composite_fonts[0].clone(); + + let mut t = session(); + let mut mine = font("CF IO a/b: \"quoted\" 日本"); + mine["parts"]["roman"]["size"] = json!(90); + t.execute("type.compositeFonts.set", &json!({"fonts": [mine, font("CF IO Kept")]})).unwrap(); + let before = steps(&t); + let read = t.execute("type.compositeFonts.import", &json!({"path": path, "apply": false})).unwrap(); + assert_eq!(read["fonts"].as_array().map(Vec::len), Some(1)); + assert_eq!(steps(&t), before); + t.execute("type.compositeFonts.import", &json!({"path": path})).unwrap(); + assert_eq!(steps(&t), before + 1); + assert_eq!(names(&t), ["CF IO a/b: \"quoted\" 日本", "CF IO Kept"]); + assert_eq!(t.doc().unwrap().doc.composite_fonts[0], saved); + // The same file again changes nothing. + t.execute("type.compositeFonts.import", &json!({"path": path})).unwrap(); + assert_eq!(steps(&t), before + 1); + + // Files that aren't composite fonts, or are too large, are refused. + for (file, bytes) in [ + ("bad.vccomposite", b"not json".to_vec()), + ("empty.vccomposite", br#"{"compositeFonts": []}"#.to_vec()), + ("twice.vccomposite", format!(r#"{{"compositeFonts": [{}, {}]}}"#, font("CF Dup"), font("cf dup")).into_bytes()), + ("large.vccomposite", vec![b' '; (4 << 20) + 1]), + ] { + let p = dir.join(file); + std::fs::write(&p, bytes).unwrap(); + assert!(t.execute("type.compositeFonts.import", &json!({"path": p.to_str().unwrap()})).is_err(), "{file}"); + assert_eq!(steps(&t), before + 1); + } + assert!(t.execute("type.compositeFonts.import", &json!({"path": path, "apply": "no"})).is_err()); + assert!(t.execute("type.compositeFonts.import", &json!({"path": ""})).is_err()); + assert!(t.execute("type.compositeFonts.import", &json!({"path": path, "data": "{}"})).is_err()); + assert!(t.execute("type.compositeFonts.import", &json!({"dataBase64": "%%%"})).is_err()); + + // Without a path, Export gives the file's text, which Import takes as data. + let data = s.execute("type.compositeFonts.export", &json!({"names": ["CF IO Other"]})).unwrap(); + assert_eq!(data["count"], 1); + let mut u = session(); + let r = u.execute("type.compositeFonts.import", &json!({"data": data["data"]})).unwrap(); + assert_eq!(r["imported"], json!(["CF IO Other"])); + assert_eq!(names(&u), ["CF IO Other"]); + // Fonts given are written in place of the document's; bad ones are refused. + let given = s.execute("type.compositeFonts.export", &json!({"fonts": [font("CF IO Given")]})).unwrap(); + assert!(given["data"].as_str().unwrap().contains("CF IO Given")); + assert!(s.execute("type.compositeFonts.export", &json!({"fonts": [font("")]})).is_err()); + assert!(s.execute("type.compositeFonts.export", &json!({"fonts": []})).is_err()); + assert!(s.execute("type.compositeFonts.export", &json!({"path": " "})).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} 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"##, + ); + assert!(w.iter().any(|w| w.contains("filter")), "{w:?}"); + assert!(effects(&d).is_empty()); +} + +/// Among many filters, art that uses one twice keeps it both times (the second use is a copy +/// under another id). +#[test] +fn a_filter_used_twice_among_many_is_kept_twice() { + let used = r##""##; + let defs: String = (0..300) + .map(|i| format!(r##""##)) + .collect(); + let (d, w) = open(&format!( + r#"{defs}{used}"# + )); + assert!(w.is_empty(), "{w:?}"); + let named = |n: &&Node| n.appearance.effects.iter().filter(|e| e.id == "svg.filter").map(|e| e.params["name"].clone()).collect::>(); + let filters: Vec> = art(&d).iter().map(named).collect(); + assert_eq!(filters, vec![vec![json!("g")], vec![json!("g")]], "{:?}", art(&d)); +} + +/// Other filters come in as SVG filters; one on a turned object is reported and left out. +#[test] +fn other_filters_are_kept_or_reported() { let (d, w) = open(r##""##); + assert!(w.is_empty(), "{w:?}"); + let fx = effects(&d); + assert_eq!((fx.len(), fx[0].id.as_str(), &fx[0].params["name"]), (1, "svg.filter", &json!("t"))); + let (d, w) = open( + r##""##, + ); + assert!(w.iter().any(|w| w.contains("filter")), "{w:?}"); + assert!(effects(&d).is_empty()); + // Past the 0.001 to 1000 points a unit of the effect measures: reported and left out too. + let (d, w) = open( + r##""##, + ); assert!(w.iter().any(|w| w.contains("filter")), "{w:?}"); assert!(effects(&d).is_empty()); } @@ -530,6 +624,114 @@ fn an_exported_outer_glow_round_faint_art_draws_as_the_canvas_does() { assert_similar(&render_artboard(&d), &resvg_render(&svg, 200, 120), 40.0, 0.02); } +/// A document with one red rectangle whose only effect is the SVG filter `xml`. +fn filtered_rect(xml: &str) -> Document { + let mut d = Document::new(200.0, 120.0); + let mut n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(40.0, 20.0, 100.0, 70.0)), + Appearance::basic(Paint::solid(Color::rgb(0.9, 0.1, 0.1)), Paint::None, 0.0), + ); + n.appearance.effects = vec![Effect { id: "svg.filter".into(), params: json!({ "xml": xml }), visible: true }]; + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +/// SVG 1.1 export writes an SVG filter's image reference as `xlink:href`, and it imports back. +#[test] +fn an_exported_filter_image_has_an_xlink_reference() { + let data = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; + let xml = format!(r#""#); + let d = filtered_rect(&xml); + let svg = export(&d, &ExportOptions::default()); + assert!(svg.contains(&format!(r#""#)), "{svg}"); + assert!(!svg.contains(" href="), "{svg}"); + let (back, w) = import_with_report(&svg).unwrap(); + assert!(w.is_empty(), "{w:?}"); + match vectorcraft_effects::raster_effects(&effects(&back)).as_slice() { + [RasterFx::Pixel(vectorcraft_effects::PixelFx::Svg(f))] => { + assert!(f.xml().contains(&format!(r#""#)), "the filter as it is drawn: {}", f.xml()) + } + other => panic!("{other:?}"), + } +} + +/// SVG export writes a filter only in a form SVG import reads back. A filter at the size limit, +/// whose id or region written out would make it longer, is left out of the SVG with the other pixel +/// effects (exports that turn pixel effects into images draw it as an image), and the SVG opens +/// without a filter that import can't read. +#[test] +fn an_exported_filter_is_one_import_reads_back() { + let max = vectorcraft_doc::SvgFilter::MAX_XML; + let close = r#""/>"#; + let short_id = r##">(), ["svg.filter"]); +} + +/// With the Latin-1 encoding, export writes each character past U+00FF as a character reference, +/// which import counts as text. A filter is measured with those references in either encoding: one +/// that passes the limit with them is left out of both, and one that fits with them is written live +/// in Latin-1 and read back. +#[test] +fn an_exported_filter_counts_the_references_latin1_writes() { + let open = r##""#; + let max = vectorcraft_doc::SvgFilter::MAX_XML; + let latin1 = ExportOptions { encoding: vectorcraft_svg::Encoding::Latin1, ..ExportOptions::default() }; + // `国` takes three bytes as UTF-8 and eight as `国`. + let xml = format!("{open}{}{close}", "\u{56FD}".repeat((max / 2 - open.len() - close.len()) / 3)); + let d = filtered_rect(&xml); + assert_eq!(effects(&d).len(), 1); + assert!(!export(&d, &ExportOptions::default()).contains(">(), ["svg.filter"]); +} + +/// SVG 1.1 has no `feDropShadow`: export writes the SVG 1.1 primitives it stands for, which draw +/// as the canvas draws the shadow and import back as a filter that draws the same. +#[test] +fn an_exported_svg_drop_shadow_is_svg_1_1_and_draws_as_the_canvas_does() { + let xml = r##""##; + let d = filtered_rect(xml); + let svg = export(&d, &ExportOptions::default()); + assert!(!svg.contains("feDropShadow") && svg.contains(" 150 && r < 200 && g < 200, "{:?}", [r, g, b]); + assert_similar(&canvas, &resvg_render(&svg, 200, 120), 40.0, 0.02); + let (back, w) = import_with_report(&svg).unwrap(); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(effects(&back).iter().map(|e| e.id.as_str()).collect::>(), ["svg.filter"]); + assert_similar(&canvas, &render_artboard(&back), 40.0, 0.02); +} + #[test] fn symbols_and_effects_export_import_export_is_stable() { // A doc with a symbol instance carrying a shadow: export → import → export is stable. diff --git a/crates/svg/tests/it/raster_paints.rs b/crates/svg/tests/it/raster_paints.rs index 221aae957..2ee1dd3bb 100644 --- a/crates/svg/tests/it/raster_paints.rs +++ b/crates/svg/tests/it/raster_paints.rs @@ -41,6 +41,29 @@ fn png_of(svg: &str) -> image::RgbaImage { image::load_from_memory(&base64_decode(data).unwrap()).unwrap().to_rgba8() } +#[test] +fn freeform_points_that_share_a_model_are_written_as_their_mix() { + let mid = |a: Color, b: Color| { + let p = |x, c| FreeformPoint::new(Point::new(x, 80.0), c); + let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() }); + g.freeform = Some(Freeform { points: vec![p(40.0, a), p(160.0, b)], ..Default::default() }); + let shape = shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 140.0)); + let d = doc_with(Node::path(NodeId(0), shape, Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0))); + let png = png_of(&export_full(&d, &ExportOptions::default(), None).svg); + png.get_pixel(png.width() / 2, png.height() / 2).0 + }; + let near = |got: [u8; 4], want: [u8; 4]| got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 3); + let got = mid(Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0)); + let want = Color::cmyk(0.5, 0.0, 0.0, 0.5).to_rgba8(1.0); + assert!(near(got, want), "CMYK: {got:?} vs {want:?}"); + let got = mid(Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0)); + let want = Color::lab(60.0, 0.0, 0.0).to_rgba8(1.0); + assert!(near(got, want), "Lab: {got:?} vs {want:?}"); + let got = mid(Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)); + let want = Color::rgb(0.5, 0.0, 0.5).to_rgba8(1.0); + assert!(near(got, want), "RGB: {got:?} vs {want:?}"); +} + #[test] fn a_freeform_fill_is_an_image_clipped_to_the_shape() { let shape = shapes::ellipse(Rect::new(20.0, 20.0, 180.0, 140.0)); diff --git a/crates/svg/tests/it/roundtrip.rs b/crates/svg/tests/it/roundtrip.rs index 25d0169c6..b13ecf160 100644 --- a/crates/svg/tests/it/roundtrip.rs +++ b/crates/svg/tests/it/roundtrip.rs @@ -501,6 +501,92 @@ fn text_on_path_options_svg_cannot_express_are_outlined() { } } +/// Type on a path with Skew, 3D Ribbon, Stair Step or Gravity, whose glyphs a `` would +/// turn with the path as Rainbow does, keeps its look as outlines, with spacing or without it; +/// Rainbow stays a ``. +#[test] +fn text_on_path_effects_svg_cannot_express_are_outlined() { + use vectorcraft_doc::PathEffect; + let mut curve = vectorcraft_geom::BezPath::new(); + curve.move_to((20.0, 150.0)); + curve.curve_to((60.0, 40.0), (140.0, 40.0), (180.0, 150.0)); + for effect in PathEffect::ALL { + for spacing in [0.0, 4.0] { + let mut d = Document::new(200.0, 200.0); + let mut t = TextObject::point(Point::ZERO, "On a curve", CharStyle { size: 14.0, ..CharStyle::default() }); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.1, end: None }; + t.path_effect = effect; + t.path_spacing = spacing; + let id = d.alloc_id(); + let (svg, warnings) = + vectorcraft_svg::export_with_report(&doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]), &ExportOptions::default()); + let live = effect == PathEffect::Rainbow && spacing == 0.0; + assert_eq!(svg.contains("`. diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index eee6e2fbf..adcc101f2 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -435,6 +435,17 @@ fn numbers_are_written_as_their_characters_where_they_are_drawn() { assert!(at(">I.\t<") < at("alpha") && at("alpha") < at(">II.\t<") && at(">II.\t<") < at("zeta") && at("zeta") < at(">III.\t<"), "{svg}"); assert!(!svg.contains(">-<"), "{svg}"); assert_drawn_in_place(&d); + // Aligned right: the gap comes before each number, which a viewer draws where the canvas does, + // against the text. + let mut t = TextObject::point(Point::new(20.0, 20.0), "alpha beta gamma delta epsilon\nzeta\neta", style(12.0)); + t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 160.0)) }; + t.para.list = Some(ParaList::numbered(NumberStyle::UpperRoman).aligned(vectorcraft_doc::text::ListAlign::Right)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + let d = doc(t); + let svg = export(&d, &ExportOptions::default()); + assert!(svg.contains(">III.<") && !svg.contains(">III.\t<") && !svg.contains(">-<"), "{svg}"); + assert_drawn_in_place(&d); let mut line = BezPath::new(); line.move_to((0.0, 50.0)); line.line_to((250.0, 50.0)); diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index d42e8f351..a95ac2dc5 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -20,6 +20,7 @@ use vectorcraft_doc::color::Color; mod alternates; mod build; mod cache; +mod composite; mod db; mod dirs; mod face; @@ -36,6 +37,7 @@ pub use build::style_weight; #[cfg(test)] use cache::GlyphCache; use cache::{CanonicalNames, Catalog, GlyphColours, GlyphImages, PostScriptNames}; +pub use composite::{CompositeCoverage, LentCompositeFonts}; #[cfg(all(not(target_arch = "wasm32"), test))] pub(crate) use dirs::fonts_outside; #[cfg(test)] @@ -47,6 +49,7 @@ pub use dirs::{ use face::Instances; #[cfg(test)] pub(crate) use face::MAX_LIVE_INSTANCES; +pub(crate) use face::PathAlignHeights; pub use face::{FALLBACK_FAMILY, FontFace, IcfMargins}; pub(crate) use glyph_pen::FlipPen; #[cfg(not(target_arch = "wasm32"))] @@ -107,6 +110,11 @@ pub struct FontDb { stamp: Mutex>, /// Faces instanced at the axis settings of type's Variable Font sliders ([`FontDb::instance`]). instances: Mutex, + /// Composite fonts (Type › Composite Fonts…) by lowercased name ([`FontDb::set_composite_font`]): + /// type naming one as its family is set in its parts' fonts. + composites: RwLock>>, + /// The composite fonts [`FontDb::lend_composite_fonts`] put in, by lowercased name. + composite_loans: Mutex>, } /// Where a database's system font scan looks: folders (read with their subfolders) and font files. diff --git a/crates/text/src/fontdb/build.rs b/crates/text/src/fontdb/build.rs index 40bcb1df1..4946bbd84 100644 --- a/crates/text/src/fontdb/build.rs +++ b/crates/text/src/fontdb/build.rs @@ -75,6 +75,7 @@ pub(super) fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Opt plans: std::sync::Mutex::new(Vec::new()), ideographic_centre: std::sync::OnceLock::new(), icf_margins: std::sync::OnceLock::new(), + path_align: std::sync::OnceLock::new(), colour_bitmaps, colour_layers, svg_glyphs, diff --git a/crates/text/src/fontdb/composite.rs b/crates/text/src/fontdb/composite.rs new file mode 100644 index 000000000..0e1d9afa8 --- /dev/null +++ b/crates/text/src/fontdb/composite.rs @@ -0,0 +1,357 @@ +//! Composite fonts (Type › Composite Fonts…) in the font database: each is listed first in the +//! font menus and resolves to its base (Kanji) font, and layout sets each character of type in one +//! in the font of the character's class ([`FontDb::composite_split`]). + +use unicode_segmentation::UnicodeSegmentation; +use vectorcraft_doc::{CharClass, CompositeFont, TextObject, TextRun}; + +use super::face::FALLBACK_FAMILY; +use super::{Arc, FontDb, FontFace, FontMatch}; + +/// A face [`FontDb::resolve`] found and how it matched. +type Resolved = Option<(Arc, FontMatch)>; + +/// How a composite font draws some text ([`FontDb::composite_coverage`]). +#[derive(Clone, Debug)] +pub struct CompositeCoverage { + /// The worst match among the parts that draw it. + pub status: FontMatch, + /// The face of the first part with that match. + pub face: Option>, + /// The characters whose part's face lacks them. + pub lacking: Vec, +} + +impl FontDb { + /// Make `font` available by its name, in place of a composite font of that name (any case). + /// The font menus and anything else that lists fonts change only when the definition does. + /// Whether it did. + pub fn set_composite_font(&self, font: &CompositeFont) -> bool { + let key = font.name.to_lowercase(); + { + let mut map = self.composites.write().unwrap_or_else(|e| e.into_inner()); + if map.get(&key).is_some_and(|f| **f == *font) { + // The same definition a guard lends: it stays when the guard drops. + let mut loans = self.composite_loans.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(loan) = loans.get_mut(&key) + && map.get(&key).is_some_and(|have| Arc::ptr_eq(have, &loan.font)) + { + loan.adopted = true; + } + return false; + } + map.insert(key, Arc::new(font.clone())); + } + self.changed(); + true + } + + /// Forget the composite font named `name` (any case). Whether there was one. + pub fn remove_composite_font(&self, name: &str) -> bool { + let removed = self.composites.write().unwrap_or_else(|e| e.into_inner()).remove(&name.to_lowercase()).is_some(); + if removed { + self.changed(); + } + removed + } + + /// The composite font named `name` (any case). + pub fn composite_font(&self, name: &str) -> Option> { + let map = self.composites.read().unwrap_or_else(|e| e.into_inner()); + if map.is_empty() { + return None; + } + map.get(&name.to_lowercase()).cloned() + } + + /// The face composite font `name` sets character `c` in (its class's part's), and whether + /// that part's font is missing. `None` when `name` is no composite font. + pub fn composite_face(&self, name: &str, c: char) -> Option<(bool, u32)> { + self.composite_resolve(name, c).map(|(face, m)| (m == FontMatch::Missing, face.id())) + } + + /// [`FontDb::composite_resolve`] for the character at byte `at` of run text `text`: the part of + /// the grapheme cluster that holds it, by the cluster's first character, as layout sets it. + pub fn composite_resolve_at(&self, name: &str, text: &str, at: usize) -> Option<(Arc, FontMatch)> { + let first = text.grapheme_indices(true).take_while(|(i, _)| *i <= at).last().and_then(|(_, g)| g.chars().next())?; + self.composite_resolve(name, first) + } + + /// The face composite font `name` sets character `c` in (its class's part's), and how that + /// part's font matched ([`FontDb::resolve`]). `None` when `name` is no composite font, or no + /// font at all is loaded. + pub fn composite_resolve(&self, name: &str, c: char) -> Option<(Arc, FontMatch)> { + let f = self.composite_font(name)?; + let (family, style) = self.part_font(&f, CharClass::of(c)); + self.resolve(&family, &style) + } + + /// The fonts (family, style) composite font `name` sets `text` in: those of the parts its + /// grapheme clusters fall in (each by its first character, as layout splits them), each once, + /// in the order first used; the Kanji part's for text without characters. `None` when `name` is + /// no composite font. + pub fn composite_fonts_for(&self, name: &str, text: &str) -> Option> { + let f = self.composite_font(name)?; + let mut classes: Vec = Vec::new(); + for g in text.graphemes(true) { + let c = g.chars().next().map_or(CharClass::Kanji, CharClass::of); + if !classes.contains(&c) { + classes.push(c); + } + } + if classes.is_empty() { + classes.push(CharClass::Kanji); + } + let mut fonts: Vec<(String, String)> = Vec::with_capacity(classes.len()); + for c in classes { + let font = self.part_font(&f, c); + if !fonts.contains(&font) { + fonts.push(font); + } + } + Some(fonts) + } + + /// How composite font `name` draws the runs of text `texts` (each split into grapheme clusters + /// on its own, as layout splits runs): the worst match among the parts their grapheme clusters + /// fall in (missing, then another style, then exact), the face of the first part with that + /// match, and the characters (neither whitespace nor control characters, sorted, each once) + /// that their part's face lacks. `None` when `name` is no composite font. + pub fn composite_coverage(&self, name: &str, texts: &[&str]) -> Option { + let f = self.composite_font(name)?; + let rank = |m: FontMatch| match m { + FontMatch::Exact => 0, + FontMatch::Style => 1, + FontMatch::Missing => 2, + }; + let mut faces: Vec<(CharClass, Resolved)> = Vec::new(); + let mut face_of = |class: CharClass| { + if let Some((_, found)) = faces.iter().find(|(c, _)| *c == class) { + return found.clone(); + } + let (family, style) = self.part_font(&f, class); + let found = self.resolve(&family, &style); + faces.push((class, found.clone())); + found + }; + let mut worst: Option<(FontMatch, Option>)> = None; + let mut lacking: Vec = Vec::new(); + let mut graphemes: Vec<&str> = texts.iter().flat_map(|t| t.graphemes(true)).collect(); + if graphemes.is_empty() { + graphemes.push(""); + } + for g in graphemes { + let class = g.chars().next().map_or(CharClass::Kanji, CharClass::of); + let found = face_of(class); + let m = found.as_ref().map_or(FontMatch::Missing, |(_, m)| *m); + if worst.as_ref().is_none_or(|(w, _)| rank(m) > rank(*w)) { + worst = Some((m, found.as_ref().map(|(face, _)| face.clone()))); + } + lacking.extend(g.chars().filter(|c| !c.is_whitespace() && !c.is_control() && !found.as_ref().is_some_and(|(face, _)| face.covers(*c)))); + } + lacking.sort_unstable(); + lacking.dedup(); + let (status, face) = worst.unwrap_or((FontMatch::Exact, None)); + Some(CompositeCoverage { status, face, lacking }) + } + + /// The family type in `family` is stored under when it is set in that font: a composite font's + /// own name, else the family of the face it resolves to (any name of a family, ヒラギノ角ゴシック, + /// is stored as its own, Hiragino Sans). `None` when the family isn't available. + pub fn stored_family(&self, family: &str, style: &str) -> Option { + if let Some(f) = self.composite_font(family) { + return Some(f.name.clone()); + } + match self.resolve(family, style) { + Some((face, m)) if m != FontMatch::Missing => Some(face.family.clone()), + _ => None, + } + } + + /// Make each of the composite fonts `fonts` available while the returned guard lives, in place + /// of a composite font of the same name that the database holds. An app's session registers + /// the composite fonts of its front document; with the guard, a document exported without a + /// session, or one that is not in front, draws its type in its own. Dropping the guard puts + /// back the font each one replaced, or forgets it, unless another guard still lends the same + /// definition or a font of that name has been set since. While one guard lends a font, another + /// guard's different definition of that name is not lent. + pub fn lend_composite_fonts(&self, fonts: &[CompositeFont]) -> LentCompositeFonts<'_> { + let mut lent = Vec::new(); + let mut changed = false; + // A font a document can't use (its name an installed family's, or a value out of range) + // is not lent: an app leaves it out when it opens the document. + let usable: Vec<&CompositeFont> = fonts.iter().filter(|f| f.check().is_ok() && !self.has_installed_family(&f.name)).collect(); + if !usable.is_empty() { + let mut map = self.composites.write().unwrap_or_else(|e| e.into_inner()); + let mut loans = self.composite_loans.lock().unwrap_or_else(|e| e.into_inner()); + for f in usable { + let key = f.name.to_lowercase(); + // A loan still in force: lend the same definition again, and leave another as it is. + if let Some(loan) = loans.get_mut(&key) + && map.get(&key).is_some_and(|have| Arc::ptr_eq(have, &loan.font)) + { + if *loan.font == *f { + loan.guards += 1; + lent.push((key, loan.font.clone())); + } + continue; + } + if map.get(&key).is_some_and(|have| **have == *f) { + continue; + } + let font = Arc::new((*f).clone()); + let previous = map.insert(key.clone(), font.clone()); + loans.insert(key.clone(), CompositeLoan { font: font.clone(), previous, guards: 1, adopted: false }); + lent.push((key, font)); + changed = true; + } + } + if changed { + self.changed(); + } + LentCompositeFonts { db: self, lent } + } + + /// The family and style composite font `f` sets characters of `class` in: its part's, or the + /// fallback font's when that part names a composite font. + fn part_font(&self, f: &CompositeFont, class: CharClass) -> (String, String) { + let p = f.parts.get(class); + if self.composite_font(&p.font).is_some() { (FALLBACK_FAMILY.to_string(), "Regular".to_string()) } else { (p.font.clone(), p.style.clone()) } + } + + /// The names of the composite fonts, sorted. + pub fn composite_font_names(&self) -> Vec { + let mut v: Vec = self.composites.read().unwrap_or_else(|e| e.into_inner()).values().map(|f| f.name.clone()).collect(); + v.sort_by_key(|n| n.to_lowercase()); + v + } + + /// The family and style that stand for composite font `name` where one font is needed (its + /// metrics, whether it is missing): its Kanji part's, or the fallback font's when that part + /// names a composite font too. `None` when `name` is no composite font. + pub(super) fn composite_base(&self, name: &str) -> Option<(String, String)> { + let f = self.composite_font(name)?; + Some(self.part_font(&f, CharClass::Kanji)) + } + + /// `t` with each run set in a composite font split into runs of one [`CharClass`] each, set in + /// that class's font: its size and baseline shift from the part's size and baseline (in + /// percent of the run's size), its scales multiplied by the part's, and Kana scaled at the + /// center raised so the em box's center stays where it was. Each grapheme cluster goes with + /// its first character. Also the index of the run of `t` each run came from. `None` when no run + /// is set in a composite font. + pub fn composite_split(&self, t: &TextObject) -> Option<(TextObject, Vec)> { + if self.composites.read().unwrap_or_else(|e| e.into_inner()).is_empty() { + return None; + } + let fonts: Vec>> = + t.runs.iter().map(|r| if r.inline.is_some() { None } else { self.composite_font(&r.style.font_family) }).collect(); + if fonts.iter().all(Option::is_none) { + return None; + } + let mut runs = Vec::with_capacity(t.runs.len()); + let mut from = Vec::with_capacity(t.runs.len()); + for (i, (r, font)) in t.runs.iter().zip(&fonts).enumerate() { + let Some(font) = font else { + runs.push(r.clone()); + from.push(i); + continue; + }; + let mut start = 0; + let mut class = None; + for (at, g) in r.text.grapheme_indices(true) { + let c = g.chars().next().map_or(CharClass::Kanji, CharClass::of); + if let Some(k) = class + && k != c + { + runs.push(self.composite_run(r.text.get(start..at).unwrap_or_default(), r, font, k)); + from.push(i); + start = at; + } + class = Some(c); + } + runs.push(self.composite_run(r.text.get(start..).unwrap_or_default(), r, font, class.unwrap_or(CharClass::Kanji))); + from.push(i); + } + Some((TextObject { runs, ..t.clone() }, from)) + } + + /// Characters `text` of run `r` (set in composite font `font`), all of class `class`, as a run + /// set in that class's part. + fn composite_run(&self, text: &str, r: &TextRun, font: &CompositeFont, class: CharClass) -> TextRun { + let p = font.parts.get(class); + let mut st = r.style.clone(); + let base = st.size; + let (family, style) = self.part_font(font, class); + st.font_family = family; + st.font_style = style; + st.font_version = None; + st.font_axes.clear(); + st.size = base * p.size / 100.0; + st.baseline_shift += base * p.baseline / 100.0; + st.h_scale *= p.h_scale / 100.0; + st.v_scale *= p.v_scale / 100.0; + if class == CharClass::Kana && p.scale_at_center && p.v_scale != 100.0 { + let centre = self.style_face(&st).map_or(0.38, |f| f.ideographic_centre()); + st.baseline_shift += st.size * centre * (1.0 - p.v_scale / 100.0); + } + TextRun { text: text.to_string(), style: st, inline: None } + } +} + +/// A composite font [`FontDb::lend_composite_fonts`] put in the database. +pub(super) struct CompositeLoan { + /// The font lent. + font: Arc, + /// The font of that name it replaced, put back when the last guard lending it drops. + previous: Option>, + /// How many guards lend it. + guards: usize, + /// Whether [`FontDb::set_composite_font`] set the same definition while it was lent: the last + /// guard to drop then leaves it in place. + adopted: bool, +} + +/// The composite fonts [`FontDb::lend_composite_fonts`] made available, given back when it drops. +#[must_use = "the fonts are given back when the guard drops"] +pub struct LentCompositeFonts<'a> { + db: &'a FontDb, + /// The lowercased names this guard lends, each with the font lent. + lent: Vec<(String, Arc)>, +} + +impl Drop for LentCompositeFonts<'_> { + fn drop(&mut self) { + if self.lent.is_empty() { + return; + } + let mut changed = false; + { + let mut map = self.db.composites.write().unwrap_or_else(|e| e.into_inner()); + let mut loans = self.db.composite_loans.lock().unwrap_or_else(|e| e.into_inner()); + for (key, font) in &self.lent { + // A later loan of that name is another guard's. + let Some(loan) = loans.get_mut(key).filter(|l| Arc::ptr_eq(&l.font, font)) else { continue }; + loan.guards -= 1; + if loan.guards > 0 { + continue; + } + let previous = loan.previous.take(); + let adopted = loan.adopted; + loans.remove(key); + // Given back unless a font of that name has been set (the same definition included) + // or removed since. + if !adopted && map.get(key).is_some_and(|have| Arc::ptr_eq(have, font)) { + match previous { + Some(p) => map.insert(key.clone(), p), + None => map.remove(key), + }; + changed = true; + } + } + } + if changed { + self.db.changed(); + } + } +} diff --git a/crates/text/src/fontdb/db.rs b/crates/text/src/fontdb/db.rs index 9cc32eeee..902a3e5aa 100644 --- a/crates/text/src/fontdb/db.rs +++ b/crates/text/src/fontdb/db.rs @@ -69,6 +69,8 @@ impl FontDb { #[cfg(not(target_arch = "wasm32"))] stamp: Mutex::new(None), instances: Mutex::new(Instances::default()), + composites: RwLock::new(HashMap::new()), + composite_loans: Mutex::new(HashMap::new()), } } @@ -150,7 +152,8 @@ impl FontDb { /// The families font menus and lists show: [`FontDb::family_list`] without the system's hidden /// families, whose names start with "." (macOS keeps ".SF NS", ".LastResort" and others for its /// own interface, and Mac apps don't list them). Those still resolve by name, for documents and - /// fallbacks that name them. Cheap to call every frame. + /// fallbacks that name them. The composite fonts ([`FontDb::composite_font_names`]) come first. + /// Cheap to call every frame. pub fn menu_family_list(&self) -> Arc<[String]> { let all = self.family_list(); let mut cache = self.menu_cache.lock().unwrap_or_else(|e| e.into_inner()); @@ -159,7 +162,8 @@ impl FontDb { { return list.clone(); } - let list: Arc<[String]> = all.iter().filter(|f| !f.starts_with('.')).cloned().collect(); + let composites = self.composite_font_names(); + let list: Arc<[String]> = composites.into_iter().chain(all.iter().filter(|f| !f.starts_with('.')).cloned()).collect(); *cache = Some((all, list.clone())); list } @@ -187,7 +191,7 @@ impl FontDb { let mut v: Vec = self.read_faces().iter().map(|f| f.family.clone()).filter(|f| !f.starts_with('.')).collect(); v.sort_by_key(|a| a.to_lowercase()); v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); - v.into() + self.composite_font_names().into_iter().chain(v).collect() } /// How a font menu labels `family`: the English name when `english` is on (Preferences › Type › diff --git a/crates/text/src/fontdb/face.rs b/crates/text/src/fontdb/face.rs index 5ab31eeae..fa59ff8ee 100644 --- a/crates/text/src/fontdb/face.rs +++ b/crates/text/src/fontdb/face.rs @@ -193,6 +193,8 @@ pub struct FontFace { pub(super) ideographic_centre: std::sync::OnceLock, /// [`Self::icf_margins`], read once. pub(super) icf_margins: std::sync::OnceLock, + /// [`Self::path_align_heights`], read once. + pub(super) path_align: std::sync::OnceLock, /// Has `sbix` or `CBDT` strikes: colour bitmap glyphs ([`FontDb::glyph_image`]). pub(super) colour_bitmaps: bool, /// Has `COLR` colour glyphs ([`FontDb::glyph_colour`]). @@ -212,6 +214,26 @@ pub struct IcfMargins { pub left: f64, } +/// The heights above the baseline, in ems, that Type on a Path's Align to Path runs along the +/// path ([`FontFace::path_align_heights`]). These are VectorCraft's own values. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct PathAlignHeights { + /// Ascender: the top of the face's em box ([`FontFace::em_box_top`]). + pub(crate) top: f64, + /// Descender (negative): the bottom of the font's bounding box (`head`), else the face's + /// descent. + pub(crate) bottom: f64, + /// Center: half the height of the top of the H above the baseline in whole thousandths of an + /// em, else half the OS/2 cap height, else the middle of the em box. + pub(crate) center: f64, +} + +/// The characters whose ink an ideographic face's em box is centered on, the first the face has. +const EM_BOX_IDEOGRAPHS: [char; 4] = ['永', '鬱', '곬', '・']; + +/// A box-drawing character whose ink, in an ideographic face, is one em tall: the em box. +const EM_BOX_CROSS: char = '┼'; + impl std::fmt::Debug for FontFace { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "FontFace({} {})", self.family, self.style) @@ -462,6 +484,111 @@ impl FontFace { *self.icf_margins.get_or_init(|| self.icf_from_base().or_else(|| self.icf_from_glyphs()).unwrap_or_default()) } + /// The lines Type on a Path's Align to Path runs along the path, in ems above the baseline + /// ([`PathAlignHeights`]): the top of the em box ([`Self::em_box_top`], else 0.88 em), the + /// bottom of the font's bounding box, and half the height of the top of the H. + pub(crate) fn path_align_heights(&self) -> PathAlignHeights { + *self.path_align.get_or_init(|| { + use skrifa::raw::TableProvider; + let em = self.upem; + let top = self.em_box_top().unwrap_or(0.88); + let bottom = + self.skrifa().and_then(|f| f.head().ok()).map(|h| f64::from(h.y_min()) / em).filter(|b| b.is_finite() && *b <= 0.0 && *b > -4.0); + // Measured from the baseline to the top of the H, as a cap height is, wherever its ink starts. + let cap = self.glyph_ink('H').map(|(_, top)| top / em).or_else(|| self.os2_cap_height()).filter(|c| c.is_finite() && *c < 2.0); + PathAlignHeights { + top, + bottom: bottom.unwrap_or(-self.descent / em), + center: cap.map_or(top - 0.5, |cap| (cap * 1000.0).round() / 2000.0), + } + }) + } + + /// The top of the face's em box, in ems above the baseline. A `BASE` table with an `ideo` + /// baseline gives it: its `idtp`, else one em above `ideo`. In a face whose OS/2 code pages + /// include Chinese, Japanese or Korean, it is the typographic ascender of the typographic box + /// (else, in a TrueType face, of the `hhea` box) when that box holds the ink of the first of 永, + /// 鬱, 곬 and ・ the face has, and is one em tall or leaves equal margins above and below the + /// ink; a face with none of them needs the box to be one em tall. Else it is the top of a ┼ + /// one em tall, else the top of an em box centered on the ink. In any other face the em box is + /// centered on the cap height: the OS/2 value, else the lower of the tops of H and O in whole + /// thousandths of an em. `None` when none of these applies. + pub(crate) fn em_box_top(&self) -> Option { + use skrifa::raw::TableProvider; + let f = self.skrifa()?; + let em = self.upem; + let sane = |v: f64| (v.is_finite() && v.abs() < 4.0).then_some(v); + // A roman baseline below the ideographic one is a damaged table: the baselines are ignored. + if let Some(h) = f.base().ok().and_then(|b| b.horiz_axis()).and_then(Result::ok).and_then(base_values) + && let Some(&ideo) = h.get(b"ideo") + && h.get(b"romn").is_none_or(|&romn| romn >= ideo) + { + return sane(h.get(b"idtp").copied().unwrap_or(ideo + em) / em); + } + // ulCodePageRange1 bits 17 to 21: JIS/Japan, Chinese Simplified, Korean Wansung, Chinese + // Traditional and Korean Johab. + let cjk = f.os2().ok().and_then(|o| o.ul_code_page_range_1()).is_some_and(|r| r & (0b1_1111 << 17) != 0); + if !cjk { + let lower_top = || { + let ((_, h), (_, o)) = (self.glyph_ink('H')?, self.glyph_ink('O')?); + Some((h.min(o) / em * 1000.0).round() / 1000.0) + }; + let cap = self.os2_cap_height().or_else(lower_top).filter(|c| c.is_finite() && *c > 0.0 && *c < 2.0)?; + return Some((cap + 1.0) * 0.5); + } + // Heights in 65,536ths of an em, as the em box is kept. + let units = |v: f64| (v / em * 65536.0).round(); + let ink = EM_BOX_IDEOGRAPHS.into_iter().find_map(|c| self.glyph_ink(c)); + // A variable font's MVAR table moves the OS/2 typographic ascender (`hasc`) and descender + // (`hdsc`); it has no tags for the `hhea` ascender and descender, which do not vary: + // https://learn.microsoft.com/en-us/typography/opentype/spec/mvar + let varied = |tag: &[u8; 4]| { + f.mvar().ok().and_then(|m| m.metric_delta(skrifa::Tag::new(tag), self.location().coords()).ok()).map_or(0.0, |d| d.to_f64()) + }; + let typo = f + .os2() + .ok() + .map(|o| (f64::from(o.s_typo_ascender()) + varied(b"hasc"), f64::from(o.s_typo_descender()) + varied(b"hdsc"))) + .filter(|(ascent, descent)| ascent > descent) + .or_else(|| { + let truetype = f.table_data(skrifa::Tag::new(b"glyf")).is_some(); + f.hhea().ok().filter(|_| truetype).map(|h| (f64::from(h.ascender().to_i16()), f64::from(h.descender().to_i16()))) + }) + .filter(|(ascent, descent)| ascent > descent); + if let Some((ascent, descent)) = typo { + let one_em = units(ascent - descent) == 65536.0; + let holds = match ink { + None => one_em, + Some((lo, hi)) => hi <= ascent && lo >= descent && (one_em || units((ascent - hi) - (lo - descent)).abs() <= 197.0), + }; + if holds { + return sane(ascent / em); + } + } + if let Some((lo, hi)) = self.glyph_ink(EM_BOX_CROSS) + && (65535.0..=65537.0).contains(&units(hi - lo)) + { + return sane(hi / em); + } + ink.and_then(|(lo, hi)| sane((lo + hi) / em * 0.5 + 0.5)) + } + + /// The bottom and top of the ink of `c`'s glyph, in font units; `None` when the face has no + /// glyph for it, or the glyph has no ink. + fn glyph_ink(&self, c: char) -> Option<(f64, f64)> { + let g = self.glyph_for(c); + if g == 0 { + return None; + } + let b = self.skrifa()?.glyph_metrics(Size::unscaled(), self.location()).bounds(GlyphId::new(g))?; + Some((f64::from(b.y_min), f64::from(b.y_max))).filter(|(lo, hi)| hi > lo) + } + + /// The cap height of the OS/2 table, in ems; `None` without one. + fn os2_cap_height(&self) -> Option { + self.skrifa()?.metrics(Size::unscaled(), self.location()).cap_height.map(|c| f64::from(c) / self.upem).filter(|c| *c > 0.0) + } + /// The height of the face that a font size measures with `reference`, in ems: 1 for the em /// box; the cap height or x height of its OS/2 table; the height of its ideographic character /// face ([`Self::icf_margins`]), or across a `vertical` line its width. None for a face whose diff --git a/crates/text/src/fontdb/lookup.rs b/crates/text/src/fontdb/lookup.rs index 010f96ffa..71e8e7ab8 100644 --- a/crates/text/src/fontdb/lookup.rs +++ b/crates/text/src/fontdb/lookup.rs @@ -82,8 +82,11 @@ impl FontDb { /// The kind of `family` (whether it sets Japanese, its class), for font menu filters: from a /// loaded face, else from the system font scan. Variable when any of its faces, loaded or /// scanned, is: a family may install static files beside a variable one. Cheap: no font file - /// is read, and a loaded family doesn't start the scan. + /// is read, and a loaded family doesn't start the scan. A composite font takes its base font's. pub fn family_traits(&self, family: &str) -> FontTraits { + if let Some((base, _)) = self.composite_base(family) { + return self.family_traits(&base); + } let loaded = { let faces = self.read_faces(); let mut of_family = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)); @@ -101,8 +104,11 @@ impl FontDb { } /// [`face`](Self::face) by any of the family's names ([`canonical`](Self::canonical)), and how - /// it matched. + /// it matched. A composite font matches as its base font does ([`Self::composite_base`]). pub fn resolve(&self, family: &str, style: &str) -> Option<(Arc, FontMatch)> { + if let Some((base, base_style)) = self.composite_base(family) { + return self.resolve(&base, &base_style); + } let (family, style) = self.canonical(family, style); let f = self.face(&family, &style)?; let m = if !f.family.eq_ignore_ascii_case(&family) { @@ -159,8 +165,12 @@ impl FontDb { /// Resolve a family + style to a face, falling back to the closest style of the family, then to /// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. `None` /// only if no font at all is loaded (the bundled fonts failed to parse, or were left out with - /// the `bundled-fonts` feature and none was added), in which case text has no glyphs. + /// the `bundled-fonts` feature and none was added), in which case text has no glyphs. A + /// composite font gives its base font's face ([`Self::composite_base`]). pub fn face(&self, family: &str, style: &str) -> Option> { + if let Some((base, base_style)) = self.composite_base(family) { + return self.face(&base, &base_style); + } let (family, style) = self.canonical(family, style); let (family, style) = (family.as_str(), style.as_str()); let found = self.find(family, style); @@ -352,8 +362,16 @@ impl FontDb { self.family_list().iter().find(|f| norm_chars(f).eq(norm_chars(name))).cloned() } - /// Is `family` available (loaded, or installed on the system)? + /// Is `family` available (loaded, installed on the system, or a composite font)? pub fn has_family(&self, family: &str) -> bool { + if self.composite_font(family).is_some() { + return true; + } + self.has_installed_family(family) + } + + /// Is `family` available as a loaded or installed font, excluding composite fonts? + pub fn has_installed_family(&self, family: &str) -> bool { let (family, _) = self.canonical(family, ""); self.is_loaded(&family) || self.read_catalog().contains_key(&family.to_ascii_lowercase()) } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 1ff72bdd2..9d96dca04 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -5,10 +5,10 @@ use std::sync::Arc; use kurbo::{Affine, BezPath, ParamCurve, PathEl, Point, Rect, Shape, Vec2}; use unicode_bidi::{BidiInfo, Level}; -use vectorcraft_doc::text::Kashida; +use vectorcraft_doc::text::{Kashida, ListAlign}; 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}; @@ -39,7 +39,7 @@ use breaking::tab_advance; use breaking::{break_line, candidates}; #[cfg(test)] pub(crate) use driver::layout_faces_per_call; -pub use driver::{decorations, fit_first_line, layout, layout_with, scaled}; +pub use driver::{decorations, fit_first_line, fit_source_lines, layout, layout_with, scaled}; use vertical::{TcyStyle, stands_upright, tate_chu_yoko, upright_cell, upright_centre, upright_extent, whole_character_widths}; #[cfg(test)] @@ -57,8 +57,9 @@ fn continues_cluster(prev: Option<&SGlyph>, g: Option<&SGlyph>) -> bool { /// The caret stops inside glyph `g` when it alone stands for several characters (grapheme clusters) /// of `text`, a ligature ([`PositionedGlyph::carets`]); `rtl` when its logical leading edge is its /// right edge; `upright` when it stands upright in vertical type, its carets then down its cell -/// ([`upright_extent`]). Empty for a glyph of one character. -fn ligature_carets(text: &str, g: &SGlyph, rtl: bool, upright: bool) -> Vec<(usize, f64)> { +/// ([`upright_extent`]) as a share of `upright`, the length in font units its own width runs down. +/// Empty for a glyph of one character. +fn ligature_carets(text: &str, g: &SGlyph, rtl: bool, upright: Option) -> Vec<(usize, f64)> { use unicode_segmentation::UnicodeSegmentation; let Some(s) = text.get(g.byte..g.byte + g.len).filter(|_| g.len > 1) else { return Vec::new() }; let starts: Vec = s.grapheme_indices(true).skip(1).map(|(i, _)| g.byte + i).collect(); @@ -66,12 +67,13 @@ fn ligature_carets(text: &str, g: &SGlyph, rtl: bool, upright: bool) -> Vec<(usi return Vec::new(); } let parts = starts.len() + 1; - let from_font = if upright { + let from_font = if let Some(length) = upright { let (cell, top) = upright_extent(g); g.face .vertical_ligature_carets(g.gid, starts.len(), top, cell) - .filter(|c| c.len() == starts.len() && cell > 0.0) - .map(|c| c.into_iter().map(|d| d / cell).collect::>()) + .filter(|c| c.len() == starts.len() && length > 0.0) + .map(|c| c.into_iter().map(|d| d / length).collect::>()) + .filter(|c| c.iter().all(|f| *f > 0.0 && *f < 1.0)) } else { let advance = g.face.advance(g.gid); g.face @@ -113,6 +115,10 @@ struct Ctx<'a> { db: &'a FontDb, text: &'a str, runs: Vec<(Range, &'a CharStyle)>, + /// The runs as the text sets them, when `runs` are the pieces a composite font splits them + /// into ([`FontDb::composite_split`]); empty otherwise. Ruby readings take their style from + /// these, so a reading in a composite font is set in its own parts. + set_runs: Vec<(Range, &'a CharStyle)>, /// The inline graphic of each run that is one (parallel to `runs`). inlines: Vec>, /// The face of each run (parallel to `runs`), looked up once ([`run_face`]). @@ -149,6 +155,16 @@ impl Ctx<'_> { self.run_at(b).and_then(|i| self.runs.get(i)).map_or(&self.default, |(_, s)| *s) } + /// The style the text sets byte `b` in: [`Self::style_at`], or for text in a composite font + /// the run's style before it was split by class ([`Self::set_runs`]). + fn set_style_at(&self, b: usize) -> &CharStyle { + let i = self.set_runs.partition_point(|(r, _)| r.end <= b); + match self.set_runs.get(i).or_else(|| self.set_runs.last()) { + Some((_, s)) => s, + None => self.style_at(b), + } + } + /// The face of [`Self::style_at`] `b` ([`FontDb::style_face`]). fn face_at(&self, b: usize) -> Option> { match self.run_at(b).and_then(|i| self.runs.get(i).map(|(_, s)| (i, *s))) { @@ -227,12 +243,16 @@ impl Ctx<'_> { /// before its glyphs `sg` and return how many glyphs went in (none for a paragraph without a /// marker). The marker's characters are set in /// [`vectorcraft_doc::text::ParaList::marker_style`], and a tab after them takes the text to - /// the next tab stop past them on the first line, the left indent counting as a stop when no - /// explicit stop comes before it. In a horizontal right-to-left paragraph, whose indents are - /// mirrored ([`line_indents`]), these are measured from the right, and a number reads left to - /// right with its period at its left. The tab keeps that width however the line is aligned or - /// justified. Each of these glyphs stands for no character of the text: length 0, at the - /// paragraph's first byte, in its first character's run, at the paragraph's embedding level. + /// the next tab stop past them on the first line, the left indent plus its list level's indent + /// ([`ParaStyle::laid_out_left_indent`]) counting as a stop when no explicit stop comes before + /// it. With the list's [`ListAlign::Right`] that tab goes before them instead, with the same + /// width: the marker ends where the text starts, and the text starts where it does with + /// [`ListAlign::Left`]. In a horizontal right-to-left paragraph, + /// whose indents are mirrored ([`line_indents`]), these are measured from the right, and a + /// number reads left to right with its period at its left. The tab keeps that width however + /// the line is aligned or justified. Each of these glyphs stands for no character of the + /// text: length 0, at the paragraph's first byte, in its first character's run, at the + /// paragraph's embedding level. fn prepend_marker(&self, pr: &Range, para: &ParaStyle, text: Option<&str>, rtl: bool, sg: &mut Vec) -> usize { let Some(text) = text.filter(|_| para.list.is_some() && !pr.is_empty()) else { return 0 }; let st = vectorcraft_doc::text::ParaList::marker_style(self.style_at(pr.start)); @@ -265,11 +285,16 @@ impl Ctx<'_> { tab.kern_shift = 0.0; tab.tcy = None; tab.vpal = None; - let pen = para.left_indent + para.first_line_indent + marker.iter().map(|g| g.adv).sum::(); - let to_indent = para.left_indent - pen; - let stop_before_indent = para.tabs.iter().any(|t| t.position > pen + EPS && t.position < para.left_indent - EPS); + let left = para.laid_out_left_indent(); + let pen = left + para.first_line_indent + marker.iter().map(|g| g.adv).sum::(); + let to_indent = left - pen; + let stop_before_indent = para.tabs.iter().any(|t| t.position > pen + EPS && t.position < left - EPS); tab.adv = if to_indent > EPS && !stop_before_indent { to_indent } else { tab_advance(¶.tabs, 0.0, pen, sg).0 }; - marker.push(tab); + if para.list.is_some_and(|l| l.align == ListAlign::Right) { + marker.insert(0, tab); + } else { + marker.push(tab); + } let n = marker.len(); sg.splice(0..0, marker); n @@ -332,9 +357,31 @@ impl Ctx<'_> { Affine::IDENTITY }; let mut m = pre * local; + // Vertical type on a path: an upright glyph's cell in font units, as (its length with the + // Proportional Metrics change to it, the height of its top above the baseline). + let path_cell = (self.vertical && self.on_path && g.inline.is_none() && g.tcy.is_none() && takes_cell(g) && stands_upright(g) && g.sy != 0.0) + .then(|| { + let (cell, top) = upright_extent(g); + let proportional = g.vpal.filter(|_| g.face.vertical_glyph(g.gid).is_some()).map_or(0.0, |a| a.advance) / g.sy; + (cell + proportional, top) + }); if self.vertical && self.on_path { // Vertical path type keeps the baseline path and turns each glyph across it. m = Affine::rotate_about(std::f64::consts::FRAC_PI_2, origin) * m; + if let Some((cell, top)) = path_cell { + // An upright glyph whose top then faces the path's end turns half a turn about the + // center of its cell (without its Character Rotation), so that its top faces the + // path's start and the characters of an upright ligature read along the path in + // order. The effects that keep the glyph's up direction fixed already turn it so on + // a path that runs to the left. + let turned = m * unturned; + let tangent = Vec2::new(angle.cos(), angle.sin()); + let along = |y: f64| (turned * Point::new(0.0, y) - origin).dot(tangent); + if along(-top) > along(cell - top) { + let centre = turned * Point::new(g.face.advance(g.gid) * 0.5, cell * 0.5 - top); + m = Affine::translate(centre.to_vec2() * 2.0) * Affine::scale(-1.0) * m; + } + } } else if let (true, Some(t)) = (self.vertical, g.tcy) { // Tate-chu-yoko: the block set across the column, centred on its em of it, then moved up // by its Up/Down and right by its Left/Right (line x runs down the column and line y @@ -408,20 +455,17 @@ impl Ctx<'_> { } else { 0.0 }; - // On a path, an upright glyph turns about its pen: its cell runs along the path between - // where the glyph's transform sets the cell's foot and its top, moved by the shaper's - // placement and a baseline shift, and slanted by the path's effect. - let (width, width_start) = match (upright && self.on_path, g.sy) { - (true, sy) if sy != 0.0 => { - // In font units, with the Proportional Metrics change to its length. - let (cell, top) = upright_extent(g); - let proportional = g.vpal.filter(|_| g.face.vertical_glyph(g.gid).is_some()).map_or(0.0, |a| a.advance) / sy; + // On a path, an upright glyph's cell runs along the path from where the glyph's transform + // sets the cell's top to its foot, moved by the shaper's placement and a baseline shift, and + // slanted by the path's effect. + let (width, width_start) = match path_cell { + Some((cell, top)) => { let tangent = Vec2::new(angle.cos(), angle.sin()); let along = |y: f64| (m * Point::new(0.0, y) - origin).dot(tangent); - let (start, end) = (along(cell + proportional - top), along(-top)); + let (start, end) = (along(-top), along(cell - top)); if start.is_finite() && end > start { (end - start, start) } else { (width, width_start) } } - _ => (width, width_start), + None => (width, width_start), }; if let Some(art) = g.inline.as_ref().and_then(|ib| ib.art) { self.out.inlines.push(InlineGlyph { run: g.run, byte: g.byte, glyph: self.out.glyphs.len(), xf: m, bounds: m.transform_rect_bbox(art) }); @@ -446,7 +490,14 @@ impl Ctx<'_> { bend: None, unturned, pivot, - carets: if g.inline.is_some() { Vec::new() } else { ligature_carets(self.text, g, g.level.is_rtl() && !self.vertical, upright) }, + // On a path its carets divide the cell its width runs along, with the Proportional + // Metrics change to it. + carets: if g.inline.is_some() { + Vec::new() + } else { + let length = upright.then(|| path_cell.map_or_else(|| upright_extent(g).0, |(cell, _)| cell)); + ligature_carets(self.text, g, g.level.is_rtl() && !self.vertical, length) + }, width, width_start, }); diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index fd663c9a9..87fa8db4d 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::stands_upright; use super::{ - Affine, BezPath, Breakpoint, Burasagari, EPS, FirstBaseline, FontDb, HyphenationOptions, Kinsoku, LeadingModel, SGlyph, hyphen_glyph, - no_line_end, no_line_start, points_for, soft_line_start, + Affine, BezPath, Breakpoint, Burasagari, EPS, FirstBaseline, FontDb, HyphenationOptions, Kinsoku, KinsokuShoriType, LeadingModel, SGlyph, + hyphen_glyph, is_cjk, no_line_end, no_line_start, points_for, soft_line_start, }; /// Vertical metrics of a line: (ascent, descent, leading, cap height, x height). @@ -192,7 +193,7 @@ pub(super) fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, /// The width of glyphs `from..to` of `sg` set on a line whose content starts `x_start` from the /// tab origin: tabs of the text take what [`tab_advance`] gives them, not the width of their -/// glyph. The tab after a list marker (length 0) keeps its own width. +/// glyph. The tab of a list marker (length 0) keeps its own width. pub(super) fn set_width(sg: &[SGlyph], from: usize, to: usize, tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 { let mut x = x_start; for (j, g) in sg.iter().enumerate().take(to).skip(from) { @@ -269,7 +270,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, and `vertical` in vertical type. #[allow(clippy::too_many_arguments)] pub(super) fn break_line( text: &str, @@ -281,6 +285,9 @@ pub(super) fn break_line( hyphenate: Option<(&HyphenationOptions, f64)>, burasagari: Burasagari, 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}') { @@ -313,7 +320,8 @@ pub(super) fn break_line( } } } - let cluster_width: f64 = if gl.ch == '\t' { + // A list marker's glyphs, its tab included, keep their own widths. + let cluster_width: f64 = if gl.ch == '\t' && gl.len > 0 { tab_advance(tabs, 0.0, x_start + x, g.get(cluster_end..).unwrap_or_default()).0 } else { g[j..cluster_end].iter().map(|glyph| glyph.adv).sum() @@ -330,6 +338,11 @@ 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, vertical) + { + return (end, false); + } break; } x += cluster_width; @@ -348,14 +361,14 @@ pub(super) fn break_line( if j >= g.len() { return (g.len(), false); } - // Hyphenate the word that overflows. - let word_start = last_break.map_or(i, |b| b.0); + // Hyphenate the word that overflows. A list marker starting the line is not part of a word. + let word_start = last_break.map_or(first, |b| b.0); let in_zone = |zone: f64| { let mut te = word_start; - while te > i && g[te - 1].is_space() { + while te > first && g[te - 1].is_space() { te -= 1; } - te > i && zone > 0.0 && width - g[i..te].iter().map(|g| g.adv).sum::() <= zone + EPS + te > first && zone > 0.0 && width - g[i..te].iter().map(|g| g.adv).sum::() <= zone + EPS }; if let Some((opts, zone)) = hyphenate && g[j].is_letter() @@ -492,6 +505,137 @@ 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 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. `vertical`: the line is a column of vertical type ([`squeeze_em`]). +#[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, + vertical: 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; + } + // 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| { + 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 * squeeze_em(&g[a], vertical)).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 <= squeeze_em(before, vertical) + 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, 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.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)] #[test] fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_ones_off_line_ends() { @@ -555,7 +699,9 @@ pub(super) fn candidates(text: &str, g: &[SGlyph], hyphenate: Option<&Hyphenatio let mut v = vec![]; let mut ws = 0; for (j, gl) in g.iter().enumerate() { - let end_of_word = gl.is_space() || gl.break_after(); + // A list marker (the glyphs of length 0 here) ends before the first word. + let marker_end = gl.len == 0 && g.get(j + 1).is_some_and(|n| n.len > 0); + let end_of_word = gl.is_space() || gl.break_after() || marker_end; if end_of_word { if let Some(opts) = hyphenate && j > ws diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index f55750e95..805c4dead 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -62,6 +62,38 @@ pub fn fit_first_line(db: &FontDb, t: &mut TextObject) -> bool { true } +/// Area type whose source lays out `lines` lines in its frame, more than fit here: a font that isn't +/// available shows in the fallback font, whose lines may reach lower, and the source may set a last +/// line whose descent passes the frame's bottom, which isn't drawn here. For importers: the frame is +/// made taller, from its bottom, by its lines' deepest descent plus 0.05 pt, when that lays out +/// more of the source's lines and no more than it has. Horizontal type only. Whether it was. +pub fn fit_source_lines(db: &FontDb, t: &mut TextObject, lines: usize) -> bool { + let TextKind::Area { frame } = &t.kind else { return false }; + if t.vertical { + return false; + } + let Some(b) = frame.bounds().filter(|b| b.height() > 1e-6) else { return false }; + let laid = layout(db, t); + let have = laid.lines.len(); + if !laid.overflow || have == 0 || have >= lines { + return false; + } + // The deepest descent of its lines (a little more, for the rounding of the layout). + let Some(descent) = laid.lines.iter().map(|l| l.descent).reduce(f64::max).filter(|d| d.is_finite() && *d > 0.0) else { return false }; + let by = (b.height() + descent + 0.05) / b.height(); + if !(by.is_finite() && by > 1.0) { + return false; + } + let taller = frame.transformed(Affine::translate((0.0, b.y0)) * Affine::scale_non_uniform(1.0, by) * Affine::translate((0.0, -b.y0))); + let short = std::mem::replace(&mut t.kind, TextKind::Area { frame: taller }); + let now = layout(db, t).lines.len(); + if now > have && now <= lines { + return true; + } + t.kind = short; + false +} + /// The underline and strikethrough bars of `t` laid out as `layout` (#847): per run and line, a /// bar from the stretch's first glyph to its last one's advance, placed and sized by the font's /// own underline and strikeout metrics through the glyph's transform (so the size, baseline shift @@ -276,8 +308,27 @@ pub(crate) fn layout_faces_per_call(db: &FontDb, t: &TextObject) -> TextLayout { l } -/// One layout pass, without fitting. +/// One layout pass, without fitting. Runs set in a composite font are laid out as the runs of one +/// class each that [`FontDb::composite_split`] makes of them; the glyphs, ruby and inline +/// graphics still name the runs of `t`. fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout { + let Some((split, from)) = db.composite_split(t) else { return layout_runs(db, t, opts, None) }; + let mut l = layout_runs(db, &split, opts, Some(t)); + let original = |run: &mut usize| *run = from.get(*run).copied().unwrap_or(*run); + l.glyphs.iter_mut().chain(l.ruby.iter_mut()).for_each(|g| original(&mut g.run)); + l.inlines.iter_mut().for_each(|i| original(&mut i.run)); + l +} + +/// [`layout_once`] for text set in no composite font: `t` itself, or with `set`, the pieces a +/// composite font splits `set` into (ruby readings take their style from `set`'s runs). +fn layout_runs(db: &FontDb, t: &TextObject, opts: &LayoutOptions, set: Option<&TextObject>) -> TextLayout { + let mut set_runs = Vec::new(); + let mut off = 0; + for r in set.map(|s| s.runs.as_slice()).unwrap_or_default() { + set_runs.push((off..off + r.text.len(), &r.style)); + off += r.text.len(); + } let text = t.plain_text(); let mut runs = Vec::with_capacity(t.runs.len()); let mut inlines = Vec::with_capacity(t.runs.len()); @@ -315,6 +366,7 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout text: &text, faces, runs, + set_runs, inlines, default: CharStyle::default(), opts, diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 79cc79ba6..e1d55eece 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, 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}; 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,9 +38,11 @@ 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. + // The tab of a list marker is on the first line, with the marker, and has a fixed width. let has_tab = sg.iter().any(|g| g.ch == '\t' && g.len > 0); let has_soft_break = sg.iter().any(|g| g.ch == '\u{2028}'); if composer != Composer::EveryLine @@ -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 { @@ -66,7 +75,7 @@ fn compose_para( // indents of a right-to-left list ([`line_indents`]) keep. while widths.len() < sg.len() { let first = widths.is_empty(); - let ind_l = para.left_indent + if first && !continued { para.first_line_indent } else { 0.0 }; + let ind_l = para.laid_out_left_indent() + if first && !continued { para.first_line_indent } else { 0.0 }; // Only the first line's first word is known here. Later lines are placed as the composed // lines need: each fits its width, so its first word does too. let word = if first { word_width(sg, 0) } else { 0.0 }; @@ -104,11 +113,11 @@ fn compose_para( } /// The list marker of each of the paragraphs `paras` of `t` (None for none): bullets, and numbers -/// counted through consecutive list paragraphs from [`TextObject::list_count`], as +/// counted per level through consecutive list paragraphs from [`TextObject::list_counts`], as /// [`vectorcraft_doc::text::ParaList::next_marker`] counts them. pub(super) fn list_markers(t: &TextObject, paras: &[Range]) -> Vec> { - let mut count = t.list_count; - paras.iter().enumerate().map(|(pi, pr)| vectorcraft_doc::text::ParaList::next_marker(t.para_at(pi).list, pr.is_empty(), &mut count)).collect() + let mut counts = t.list_counts(); + paras.iter().enumerate().map(|(pi, pr)| vectorcraft_doc::text::ParaList::next_marker(t.para_at(pi).list, pr.is_empty(), &mut counts)).collect() } /// Whether paragraph `para` (right to left when `rtl`) has its indents mirrored: a horizontal @@ -120,9 +129,10 @@ fn mirrors_indents(para: &ParaStyle, rtl: bool, vertical: bool) -> bool { /// The indents at the left and at the right of a line of paragraph `para` (its first line when /// `first`). With `mirror` ([`mirrors_indents`]) the left and first-line indents are measured from /// the right and the right indent from the left: the bullet of a right-to-left list hangs at the -/// right, and the lines it wraps to line up with the text after the bullet. +/// right, and the lines it wraps to line up with the text after the bullet. A list level past the +/// first adds to the left indent ([`ParaStyle::laid_out_left_indent`]), mirrored with it. fn line_indents(para: &ParaStyle, first: bool, mirror: bool) -> (f64, f64) { - let start = para.left_indent + if first { para.first_line_indent } else { 0.0 }; + let start = para.laid_out_left_indent() + if first { para.first_line_indent } else { 0.0 }; if mirror { (para.right_indent, start) } else { (start, para.right_indent) } } @@ -284,6 +294,8 @@ struct PlacementContext<'a> { zone: f64, index: usize, para_line: usize, + /// Optical Margin Alignment or Roman Hanging Punctuation is on. + hanging: bool, } struct LinePosition { @@ -292,13 +304,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; @@ -327,10 +342,17 @@ 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, 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; + (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) { @@ -352,7 +374,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 }); } } @@ -380,10 +402,17 @@ 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>, kashidas: Vec<(usize, f64)>, + /// Right-aligned area type: the line's last tab, which takes the text after it to the right + /// edge (the text before it sits at its stops, from the left edge). + last_tab: Option, } fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut paragraph: ParagraphData<'_, '_>) -> bool { @@ -414,6 +443,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 }; @@ -437,6 +467,9 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut true } +/// The geometry of the line of `paragraph` from glyph `index`, placed at `position`: where it +/// ends with its trailing spaces trimmed, its hyphen, its room and margins, the glyphs that hang or +/// stay out of justification, and whether it is the paragraph's last line. fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData<'_, '_>, position: LinePosition, index: usize) -> LineGeometry { let para = paragraph.para; let sg = ¶graph.sg; @@ -544,7 +577,11 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData // Justification adds nothing to the list marker's glyphs. let mut held = held; held.extend(index..paragraph.marker.min(body)); - let last_of_para = position.end >= n || position.end.checked_sub(1).and_then(|i| sg.get(i)).is_some_and(|g| g.ch == '\u{2028}'); + // A line a soft return ends is justified as a wrapped one is: only the paragraph's last line + // keeps the alignment of Justify Left, Center or Right. A paragraph that ends in a soft return + // ends with the empty line after it (`run_paragraph_lines`). + let soft_return = position.end > index && sg.get(position.end - 1).is_some_and(|g| g.ch == '\u{2028}'); + let last_of_para = position.end >= n && !soft_return; LineGeometry { position, index, @@ -571,12 +608,24 @@ 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)) + }) +} + +// May a pushed-in line close a CJK gap after `g`? Not after a list marker or an inline graphic, +// which the push-in's room does not count either. +fn may_squeeze(g: &SGlyph) -> bool { + g.len > 0 && g.inline.is_none() +} + +// How much a pushed-in line's `squeeze` adds to the CJK gap after `g`. +fn squeeze_after(g: &SGlyph, squeeze: f64, vertical: bool) -> f64 { + if may_squeeze(g) { squeeze * squeeze_em(g, vertical) } else { 0.0 } } // What justification may change of glyph `j`: nothing of a `held` glyph, and no glyph @@ -592,6 +641,9 @@ fn flow_line_flex(glyph: usize, flex: &[Flex], held: &[usize], hung: &[usize]) - value } +/// How `line` of `paragraph` is aligned: where it starts, what justification adds to its spaces, +/// gaps and glyphs, its kashidas, and, in right-aligned area type, its last tab, which takes the +/// text after it to the right edge. fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &LineGeometry) -> LineJustification { let para = paragraph.para; let sg = ¶graph.sg; @@ -638,7 +690,20 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin 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) && may_squeeze(&sg[j])) + .map(|j| squeeze_em(&sg[j], cx.vertical)) + .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; @@ -666,7 +731,14 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin } } let position = &line.position; - let start_x = if justify { + // Right-aligned area type with a tab: the text before the last tab sits at its stops, from the + // left edge, and the text after it against the right edge, as Illustrator's own layout in a + // user's `.ai` sets a price list. Centred and justified lines are aligned as a whole. + let last_tab = (para.justify == Justify::Right && !justify && !paragraph.rtl && paragraph.regions.is_some()) + // A tab ending the line counts: its text sits at the left edge, the tab up to the right one. + .then(|| (line.index..line.position.end).rev().find(|&j| sg.get(j).is_some_and(|g| g.ch == '\t' && g.len > 0))) + .flatten(); + let start_x = if justify || pushed_in || last_tab.is_some() { line.ax0 - line.opt_l } else if paragraph.regions.is_none() { match align { @@ -681,9 +753,43 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin _ => 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, last_tab } } +/// The advance and leader of tab `glyph_index` of `line`, at pen position `x`: up to its stop +/// ([`tab_advance`], measured from `tab_origin` less `tab_shift`), or, for the last tab of +/// right-aligned area type ([`LineJustification::last_tab`]), up to where the rest of the line +/// ends on the right edge. The tab after a list marker keeps the width `prepend_marker` gave it +/// (`advance`). +fn tab_glyph<'p>( + paragraph: &'p ParagraphData<'_, '_>, + line: &LineGeometry, + justification: &LineJustification, + glyph_index: usize, + x: f64, + (tab_origin, tab_shift): (f64, f64), + advance: f64, +) -> (f64, &'p str) { + let sg = ¶graph.sg; + // The tab of a list marker keeps the width `prepend_marker` gave it. + if !sg.get(glyph_index).is_some_and(|g| g.len > 0) { + return (advance, ""); + } + let (advance, leader) = tab_advance(¶graph.para.tabs, tab_origin, x - tab_shift, sg.get(glyph_index + 1..line.trimmed).unwrap_or_default()); + if justification.last_tab != Some(glyph_index) { + return (advance, leader); + } + // The width of what follows up to the line's end (nothing, when the tab ends the line), with + // the hyphen a hyphenated line ends with. + let after = sg.get(glyph_index + 1..line.trimmed).unwrap_or_default(); + let hyphen = line.hyphen.as_ref().map_or(0.0, |g| g.adv); + let rest = if after.is_empty() { 0.0 } else { after.iter().map(|g| g.adv).sum::() - line.end_trim + hyphen }; + ((line.ax1 + line.opt_r - rest - x).max(0.0), leader) +} + +/// Lay out `line` of `paragraph` as `justification` aligns it: each glyph placed along the +/// baseline (tabs as [`tab_glyph`] sets them), its hyphen, and the line's record, then the pen +/// moved on past it. fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData<'_, '_>, line: &LineGeometry, justification: &LineJustification) { let para = paragraph.para; let sg = ¶graph.sg; @@ -723,10 +829,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData let mut scale = 1.0; let mut leader = ""; if glyph.ch == '\t' { - // The tab after a list marker keeps the width `prepend_marker` gave it. - if glyph.len > 0 { - (advance, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[glyph_index + 1..trimmed.max(glyph_index + 1)]); - } + (advance, leader) = tab_glyph(paragraph, line, justification, glyph_index, x, (tab_origin, tab_shift), advance); } else if glyph_index < body { let flex = flow_line_flex(glyph_index, ¶graph.flex, &line.held, &line.hung); if is_word_space(glyph) { @@ -737,9 +840,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 + squeeze_after(glyph, justification.squeeze, 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()) @@ -764,7 +867,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData if glyph_index < paragraph.marker { let at = cx.out.glyphs.len(); marker_glyphs = Some(marker_glyphs.map_or(at..at + 1, |range: Range| range.start.min(at)..range.end.max(at + 1))); - if glyph_index + 1 == paragraph.marker { + if glyph.ch == '\t' { marker_tab = at; } } diff --git a/crates/text/src/layout/on_path.rs b/crates/text/src/layout/on_path.rs index 14bc3ef4b..aafe17531 100644 --- a/crates/text/src/layout/on_path.rs +++ b/crates/text/src/layout/on_path.rs @@ -6,6 +6,7 @@ use super::{ Affine, ArcPath, BezPath, Ctx, GlyphBend, Justify, LineInfo, ListMarker, ParamCurve, PathAlign, PathData, PathEffect, PathEl, Point, Range, SGlyph, Shape, TextObject, Vec2, style_metrics, }; +use crate::fontdb::PathAlignHeights; /// The distance along type on a path each glyph takes: its advance measured `spacing` points above /// the path (Type on a Path Options › Spacing), so glyphs close up round the outside of a curve and @@ -315,16 +316,25 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, cx.out.overflow = true; fits = sg.get(fit..).and_then(|rest| rest.iter().map(|g| g.byte).min()).unwrap_or(text_len); } - // Align to Path: how far down (glyph space) the type moves to run its ascender, centre or - // descender along the path instead of its baseline. - let rise = match t.path_align { - PathAlign::Baseline => 0.0, - PathAlign::Ascender => m.0, - PathAlign::Descender => -m.1, - PathAlign::Center => (m.0 - m.1) * 0.5, + // Align to Path: how far down (glyph space) a glyph moves to run the top of its em box, half the + // height of the top of its H or the bottom of its font's bounding box along the path instead of its + // baseline, each glyph by its own face, size and vertical scale. In vertical type the glyph + // moves half its em (with its horizontal scale) for Ascender and Descender, and stays for Center. + // An inline graphic moves as a character of its run would, by its run's vertical scale in both. + let vertical = cx.vertical; + let rise_of = |g: &SGlyph| { + let em = g.face.units_per_em() * if vertical { g.sx } else { g.sy }; + let h = if vertical { PathAlignHeights { top: 0.5, bottom: -0.5, center: 0.0 } } else { g.face.path_align_heights() }; + match t.path_align { + PathAlign::Baseline => 0.0, + PathAlign::Ascender => h.top * em, + PathAlign::Descender => h.bottom * em, + PathAlign::Center => h.center * em, + } }; let mut x = 0.0; for (g, &step) in sg.iter().zip(&steps).take(fit) { + let rise = rise_of(g); let s = s0 + x; let (p, dir) = ap.at(s + step * 0.5); let angle = dir.y.atan2(dir.x); diff --git a/crates/text/src/layout/ruby.rs b/crates/text/src/layout/ruby.rs index 1cf34e24c..34cb71f09 100644 --- a/crates/text/src/layout/ruby.rs +++ b/crates/text/src/layout/ruby.rs @@ -127,7 +127,10 @@ fn ruby_piece_for_line( ) -> Option { let mut x0 = f64::INFINITY; let mut x1 = f64::NEG_INFINITY; + // The size the base is drawn at (where the reading goes), and the size the text sets it in (what + // the reading is scaled from: a composite font's part scales the reading itself). let mut base_size: f64 = 0.0; + let mut set_size: f64 = 0.0; let mut run = 0; let mut byte = start; for (index, &glyph_index) in glyph_indices.iter().enumerate() { @@ -135,6 +138,7 @@ fn ruby_piece_for_line( x0 = x0.min(glyph.origin.x); x1 = x1.max(glyph.origin.x + glyph.advance); base_size = base_size.max(cx.style_at(glyph.byte).size); + set_size = set_size.max(cx.set_style_at(glyph.byte).size); if index == 0 { run = glyph.run; byte = glyph.byte; @@ -143,8 +147,9 @@ fn ruby_piece_for_line( if !(x0.is_finite() && x1.is_finite()) || base_size <= 0.0 { return None; } - let mut style = cx.style_at(byte).clone(); - style.size = base_size * ratio; + // The base's style as the text sets it: a reading in a composite font is set in its parts. + let mut style = cx.set_style_at(byte).clone(); + style.size = set_size * ratio; style.leading = None; style.baseline_shift = 0.0; style.tracking = 0.0; diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index aec815641..30f8d76eb 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -78,7 +78,20 @@ fn indic_hyphenation_reaches_both_composers_without_splitting_marks() { } let cands = candidates(&text, &glyphs, Some(&opts), Kinsoku::None); assert_eq!(cands.iter().filter(|b| b.hyphen > 0.0).map(|b| b.end).collect::>(), (2..16).step_by(2).collect::>(), "{tag}"); - let (end, hyphen) = break_line(&text, &glyphs, 0, 4.5, &[], 0.0, Some((&opts, 0.0)), Burasagari::None, Kinsoku::None); + let (end, hyphen) = break_line( + &text, + &glyphs, + 0, + 4.5, + &[], + 0.0, + Some((&opts, 0.0)), + Burasagari::None, + Kinsoku::None, + KinsokuShoriType::PushOutOnly, + false, + false, + ); assert_eq!((end, hyphen), (4, true), "{tag}"); let params = Params { justify_last: false, ragged: true, tolerance: 1.0, uniform_from: 0, hyphen_limit: 0 }; let lines = compose(&glyphs, &|_| 4.5, &|_| 0.0, &cands, &[], ¶ms).expect("every-line composition"); @@ -114,6 +127,7 @@ fn composition_trim_stays_along_the_line_when_a_character_rotates() { db: &db, text: "A", runs: vec![(0..1, &style)], + set_runs: Vec::new(), inlines: vec![None], faces: vec![std::cell::OnceCell::new()], default: style.clone(), @@ -147,7 +161,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); + 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"); @@ -160,11 +175,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); + 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); + let (second_end, hyphenated) = + 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"); @@ -219,7 +236,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); + 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"); @@ -227,7 +245,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); + 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"); @@ -236,11 +255,121 @@ 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, 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, 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.sx = 10.0 / g.face.units_per_em(); + g.sy = g.sx; + } + 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, false).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); +} + +/// 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, 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/lib.rs b/crates/text/src/lib.rs index d7bc1d444..27d35c170 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -36,9 +36,9 @@ pub use bitmap::GlyphImage; pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont}; pub use features::{EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, MAX_STYLE_FEATURES, OtFeatures, explicit_ligatures, ligatures_suppressed_by}; pub use fontdb::{ - FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, IcfMargins, PlatformFontFiles, StylisticSet, WantedFont, - WantedFonts, app_font_dir, is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, set_user_font_dirs, set_user_font_dirs_confined, - style_weight, system_font_dirs, user_font_dirs, + CompositeCoverage, FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, IcfMargins, LentCompositeFonts, + PlatformFontFiles, StylisticSet, WantedFont, WantedFonts, app_font_dir, is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, + set_user_font_dirs, set_user_font_dirs_confined, style_weight, system_font_dirs, user_font_dirs, }; pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions}; pub use layout::{envelope_ink, register_envelope_ink}; @@ -47,7 +47,7 @@ pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader}; pub use kept::{KeptLayoutsFrame, LayoutStamp, keep_layout, take_kept_layout}; use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2}; -pub use layout::{decorations, fit_first_line, layout, layout_with, scaled}; +pub use layout::{decorations, fit_first_line, fit_source_lines, layout, layout_with, scaled}; pub use vectorcraft_doc::TextObject; pub use vectorcraft_doc::{AreaFit, FirstBaseline, TextFlow, VerticalAlign}; @@ -170,7 +170,7 @@ pub struct PositionedGlyph { /// taken off, plus the manual kerning of right-to-left text, which moves the glyph right so that /// the space it adds lies on the glyph's left. 0 for a glyph standing upright or set across the /// column in vertical type, and for an inline graphic. An upright glyph in vertical type on a - /// path turns about its pen, and its cell starts one cell length before the cell's top: 0.12 em + /// path stands with its top toward the path's start, and its cell starts at its top: 0.12 em /// before the pen without the font's vertical metrics. pub width_start: f64, } @@ -331,7 +331,8 @@ pub struct ListMarker { /// takes the text to the next tab stop. pub glyphs: std::ops::Range, /// The tab's glyph, which draws nothing: the last of [`Self::glyphs`], or the first in a - /// right-to-left paragraph. + /// right-to-left paragraph. A marker aligned right + /// ([`vectorcraft_doc::text::ListAlign::Right`]) has it the other way round. pub tab: usize, /// The marker's characters ("•"). pub text: String, diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 2f591b6e0..cbb2c20d2 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -855,6 +855,44 @@ pub fn vertical_ligature_caret_font(caret: i16) -> Option> { Some(builder.build()) } +/// [`vertical_ligature_caret_font`] with its one caret at y = `caret` font units, f + i joined by +/// `ccmp` (which shaping applies top to bottom too, unlike `liga`), and its `GPOS` replaced by a +/// `vpal` single adjustment that changes the ligature glyph's advance down the column by +/// `y_advance` font units. `None` when it can't be built. +pub fn proportional_vertical_ligature_caret_font(caret: i16, y_advance: i16) -> Option> { + use write_fonts::tables::gpos::{Gpos, PositionLookup, PositionLookupList, SinglePos, ValueRecord}; + use write_fonts::tables::gsub::{Gsub, Ligature, LigatureSet, LigatureSubstFormat1, SubstitutionLookup, SubstitutionLookupList}; + use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord}; + use write_fonts::types::GlyphId16; + + let base = vertical_ligature_caret_font(caret)?; + let font = skrifa::FontRef::new(&base).ok()?; + let gid = |c| Some(GlyphId16::new(source_sans_gid(c)?)); + let ligature = gid(LIGATURE_CARET_GLYPH)?; + let langsys = || Script::new(Some(LangSys::new(vec![0])), vec![]); + let scripts = || ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), langsys()), ScriptRecord::new(Tag::new(b"latn"), langsys())]); + let subst = LigatureSubstFormat1::new( + CoverageFormat1::new(vec![gid('f')?]).into(), + vec![LigatureSet::new(vec![Ligature::new(ligature, vec![gid('i')?])])], + ); + let features = FeatureList::new(vec![FeatureRecord::new(Tag::new(b"ccmp"), Feature::new(None, vec![0]))]); + let lookups = SubstitutionLookupList::new(vec![SubstitutionLookup::Ligature(Lookup::new(Default::default(), vec![subst]))]); + let gsub = Gsub::new(scripts(), features, lookups); + let pos = SinglePos::format_1(CoverageFormat1::new(vec![ligature]).into(), ValueRecord::new().with_y_advance(y_advance)); + let features = FeatureList::new(vec![FeatureRecord::new(Tag::new(b"vpal"), Feature::new(None, vec![0]))]); + let lookups = PositionLookupList::new(vec![PositionLookup::Single(Lookup::new(Default::default(), vec![pos]))]); + let mut builder = FontBuilder::new(); + builder.add_table(&gsub).ok()?; + builder.add_table(&Gpos::new(scripts(), features, lookups)).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`variable_ligature_caret_font`]. pub const VARIABLE_LIGATURE_CARET_FAMILY: &str = "Varicaret Sans"; @@ -1035,6 +1073,160 @@ pub fn tall_ideograph_font() -> Option> { Some(data) } +/// A family of [`path_align_font`]: OS/2 cap height 660, H and O. +pub const PATH_ALIGN_ROMAN_FAMILY: &str = "Pathalign Roman Sans"; +/// A family of [`path_align_font`]: no cap height, no H or O, and a `head` box above the baseline. +pub const PATH_ALIGN_BARE_FAMILY: &str = "Pathalign Bare Sans"; +/// A family of [`path_align_font`]: a `BASE` table with `ideo` at -150 and no `idtp`. +pub const PATH_ALIGN_BASE_FAMILY: &str = "Pathalign Base Sans"; +/// A family of [`path_align_font`]: Japanese code page, 永 on the ⌠ outline (ink -51 to 949). +pub const PATH_ALIGN_IDEOGRAPH_FAMILY: &str = "Pathalign Ideograph Sans"; +/// A family of [`path_align_font`]: as [`PATH_ALIGN_IDEOGRAPH_FAMILY`], with ┼ on the | outline +/// (ink -250 to 750, one em). +pub const PATH_ALIGN_CROSS_FAMILY: &str = "Pathalign Cross Sans"; +/// A family of [`path_align_font`]: Japanese code page, 永 on the H outline (ink 0 to 656), and a +/// typographic box from -100 to 900. +pub const PATH_ALIGN_TYPO_FAMILY: &str = "Pathalign Typo Sans"; +/// A family of [`path_align_font`]: as [`PATH_ALIGN_TYPO_FAMILY`], with a typographic box of 0 +/// to 0 and an `hhea` box from -100 to 900. +pub const PATH_ALIGN_HHEA_FAMILY: &str = "Pathalign Hhea Sans"; +/// A family of [`path_align_font`]: as [`PATH_ALIGN_TYPO_FAMILY`], as a variable font with one +/// weight axis (400–700, named instances Regular and Bold) whose `MVAR` table raises the +/// typographic ascender and descender by 100 at 700: a box from 0 to 1,000 in Bold. +pub const PATH_ALIGN_VARIABLE_FAMILY: &str = "Pathalign Variable Sans"; +/// A family of [`path_align_font`]: Japanese code page, with no reference ideograph and no ┼. +pub const PATH_ALIGN_NONE_FAMILY: &str = "Pathalign None Sans"; +/// A family of [`path_align_font`]: no cap height, and H and O swapped (H top 668, O top 656). +pub const PATH_ALIGN_LOWER_FAMILY: &str = "Pathalign Lower Sans"; +/// A family of [`path_align_font`]: OS/2 cap height 660, O and no H, and a `BASE` table as +/// [`PATH_ALIGN_BASE_FAMILY`]'s. +pub const PATH_ALIGN_CAPITAL_FAMILY: &str = "Pathalign Capital Sans"; +/// A family of [`path_align_font`]: as [`PATH_ALIGN_IDEOGRAPH_FAMILY`], with a typographic box +/// from -101 to 1,002: margins of 53 and 50 round 永's ink. +pub const PATH_ALIGN_MARGINS_FAMILY: &str = "Pathalign Margins Sans"; +/// A family of [`path_align_font`]: as [`PATH_ALIGN_IDEOGRAPH_FAMILY`], with a typographic box +/// from -101 to 1,003: margins of 54 and 50 round 永's ink. +pub const PATH_ALIGN_UNEVEN_FAMILY: &str = "Pathalign Uneven Sans"; +/// A family of [`path_align_font`]: a `BASE` table with `ideo` at -150 and `romn` below it at +/// -200. +pub const PATH_ALIGN_DAMAGED_FAMILY: &str = "Pathalign Damaged Sans"; + +/// The bundled Source Sans 3 Regular (1,000 units to the em, typographic box -326 to 1,000, `head` +/// box bottom -295, H top 656, O top 668) without its `BASE` table, as one of the +/// `PATH_ALIGN_*_FAMILY` faces, which map x, and H and O except where the family says otherwise. +/// `None` when it can't be built. +pub fn path_align_font(family: &str) -> Option> { + use write_fonts::tables::base::{Axis, Base, BaseCoord, BaseScript, BaseScriptList, BaseScriptRecord, BaseTagList, BaseValues}; + use write_fonts::tables::mvar::{Mvar, ValueRecord}; + use write_fonts::tables::variations::{ItemVariationData, ItemVariationStore, RegionAxisCoordinates, VariationRegion, VariationRegionList}; + use write_fonts::types::MajorMinor; + + let typo = family == PATH_ALIGN_TYPO_FAMILY || family == PATH_ALIGN_VARIABLE_FAMILY; + let ideograph = [PATH_ALIGN_IDEOGRAPH_FAMILY, PATH_ALIGN_CROSS_FAMILY, PATH_ALIGN_MARGINS_FAMILY, PATH_ALIGN_UNEVEN_FAMILY].contains(&family); + let japanese = typo || ideograph || [PATH_ALIGN_HHEA_FAMILY, PATH_ALIGN_NONE_FAMILY].contains(&family); + let mut mapping = vec![('x', 'x')]; + match family { + PATH_ALIGN_BARE_FAMILY => {} + PATH_ALIGN_LOWER_FAMILY => mapping.extend([('H', 'O'), ('O', 'H')]), + PATH_ALIGN_CAPITAL_FAMILY => mapping.push(('O', 'O')), + _ => mapping.extend([('H', 'H'), ('O', 'O')]), + } + if ideograph { + mapping.push(('永', '⌠')); + } + if family == PATH_ALIGN_CROSS_FAMILY { + mapping.push(('┼', '|')); + } + if typo || family == PATH_ALIGN_HHEA_FAMILY { + mapping.push(('永', 'H')); + } + let mut data = remapped_source_sans(family, &format!("{}-Regular", family.replace(' ', "")), &mapping)?; + let tables: Vec<(Tag, usize)> = { + let font = skrifa::FontRef::new(&data).ok()?; + font.table_directory.table_records().iter().map(|r| (Tag::new(&r.tag().to_be_bytes()), r.offset() as usize)).collect() + }; + for (tag, at) in tables { + if tag == Tag::new(b"OS/2") { + if japanese { + // ulCodePageRange1 at bytes 78 to 82: bit 17, JIS/Japan. + data.get_mut(at + 78..at + 82)?.copy_from_slice(&(1_u32 << 17).to_be_bytes()); + } + if typo { + // sTypoAscender and sTypoDescender at bytes 68 to 72. + data.get_mut(at + 68..at + 72)?.copy_from_slice(&[900_i16.to_be_bytes(), (-100_i16).to_be_bytes()].concat()); + } + if family == PATH_ALIGN_HHEA_FAMILY { + data.get_mut(at + 68..at + 72)?.copy_from_slice(&[0, 0, 0, 0]); + } + if family == PATH_ALIGN_MARGINS_FAMILY || family == PATH_ALIGN_UNEVEN_FAMILY { + let top: i16 = if family == PATH_ALIGN_MARGINS_FAMILY { 1002 } else { 1003 }; + data.get_mut(at + 68..at + 72)?.copy_from_slice(&[top.to_be_bytes(), (-101_i16).to_be_bytes()].concat()); + } + if family == PATH_ALIGN_BARE_FAMILY || family == PATH_ALIGN_LOWER_FAMILY { + // sCapHeight at bytes 88 to 90. + data.get_mut(at + 88..at + 90)?.copy_from_slice(&0_i16.to_be_bytes()); + } + } else if tag == Tag::new(b"hhea") && family == PATH_ALIGN_HHEA_FAMILY { + // ascender and descender at bytes 4 to 8. + data.get_mut(at + 4..at + 8)?.copy_from_slice(&[900_i16.to_be_bytes(), (-100_i16).to_be_bytes()].concat()); + } else if tag == Tag::new(b"head") && family == PATH_ALIGN_BARE_FAMILY { + // yMin at bytes 38 to 40. + data.get_mut(at + 38..at + 40)?.copy_from_slice(&10_i16.to_be_bytes()); + } + } + // Source Sans 3's own `BASE` table (`ideo` -170) is left out. + let font = skrifa::FontRef::new(&data).ok()?; + let mut builder = FontBuilder::new(); + if [PATH_ALIGN_BASE_FAMILY, PATH_ALIGN_CAPITAL_FAMILY, PATH_ALIGN_DAMAGED_FAMILY].contains(&family) { + let romn = if family == PATH_ALIGN_DAMAGED_FAMILY { -200 } else { 0 }; + let tags = BaseTagList::new([b"ideo", b"romn"].map(Tag::new).to_vec()); + let values = BaseValues::new(1, [-150, romn].map(BaseCoord::format_1).to_vec()); + let latn = BaseScriptRecord::new(Tag::new(b"latn"), BaseScript::new(Some(values), None, vec![])); + let axis = Axis::new(Some(tags), BaseScriptList::new(vec![latn])); + builder.add_table(&Base::new(Some(axis), None)).ok()?; + } + if family == PATH_ALIGN_VARIABLE_FAMILY { + let region = VariationRegion::new(vec![RegionAxisCoordinates::new(F2Dot14::from_f32(0.0), F2Dot14::from_f32(1.0), F2Dot14::from_f32(1.0))]); + // Two delta sets (hasc, hdsc) of one 16-bit delta each for the one region. + let data = ItemVariationData::new(2, 1, vec![0], [100_i16.to_be_bytes(), 100_i16.to_be_bytes()].concat()); + let store = ItemVariationStore::new(VariationRegionList::new(1, vec![region]), vec![Some(data)]); + let records = vec![ValueRecord::new(Tag::new(b"hasc"), 0, 0), ValueRecord::new(Tag::new(b"hdsc"), 0, 1)]; + let axis = VariationAxisRecord { + axis_tag: Tag::new(b"wght"), + min_value: Fixed::from_i32(400), + default_value: Fixed::from_i32(400), + max_value: Fixed::from_i32(700), + flags: 0, + axis_name_id: NameId::new(256), + }; + let instance = |name: u16, wght: i32| InstanceRecord { + subfamily_name_id: NameId::new(name), + flags: 0, + coordinates: vec![Fixed::from_i32(wght)], + post_script_name_id: None, + }; + let fvar = Fvar::new(AxisInstanceArrays::new(vec![axis], vec![instance(257, 400), instance(258, 700)])); + let name = name_table(&[ + (1, family), + (2, "Regular"), + (4, "Pathalign Variable Sans Regular"), + (6, "PathalignVariableSans-Regular"), + (256, "Weight"), + (257, "Regular"), + (258, "Bold"), + ])?; + builder.add_raw(Tag::new(b"name"), name); + builder.add_table(&fvar).ok()?.add_table(&Mvar::new(MajorMinor::VERSION_1_0, Some(store), records)).ok()?; + } + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) && tag != Tag::new(b"BASE") { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`soft_hyphen_outline_font`]. pub const SOFT_HYPHEN_OUTLINE_FAMILY: &str = "Soft Hyphen Profile Sans"; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 6183278ed..68fe65528 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -9,6 +9,7 @@ mod bidi; mod bitmaps; mod colr; mod combos; +mod composite; mod devanagari; mod embed; mod fit; @@ -305,6 +306,39 @@ fn area_alignment_and_justify() { assert!((l.lines[0].x1 - frame.x1).abs() < 1e-6); } +/// A source that sets a last line whose descent passes its frame's bottom: the frame grows from its +/// bottom by its lines' deepest descent plus 0.05 pt, so that line shows, and only as many lines as +/// the source has. +#[test] +fn fit_source_lines_keeps_the_last_lines_the_source_sets() { + let text = "one\ntwo\nthree"; + let tall = layout(db(), &area(text, style(12.0), Rect::new(0.0, 0.0, 200.0, 400.0), Justify::Left)); + let third = &tall.lines[2]; + // The third baseline is in the frame, its descent isn't. + let frame = Rect::new(0.0, 0.0, 200.0, third.baseline + third.descent / 2.0); + let mut t = area(text, style(12.0), frame, Justify::Left); + let short = layout(db(), &t); + assert!(short.overflow && short.lines.len() == 2); + // A source with no more lines than fit leaves the frame as it is. + assert!(!fit_source_lines(db(), &mut t, 2)); + assert_eq!(layout(db(), &t).lines.len(), 2); + assert!(fit_source_lines(db(), &mut t, 3)); + let fitted = layout(db(), &t); + assert!(!fitted.overflow && fitted.lines.len() == 3); + let TextKind::Area { frame: grown } = &t.kind else { panic!("area type") }; + let grown = grown.bounds().unwrap(); + assert!(grown.y0 == frame.y0 && grown.y1 > frame.y1 && grown.y1 <= frame.y1 + third.descent + 0.1, "{grown:?}"); + // Fitted, nothing more to do. + assert!(!fit_source_lines(db(), &mut t, 3)); + // More than a descent short: a line the source can't have set in its frame stays out. + let frame = Rect::new(0.0, 0.0, 200.0, tall.lines[1].baseline + tall.lines[1].descent + 1.0); + let mut t = area(text, style(12.0), frame, Justify::Left); + assert!(!fit_source_lines(db(), &mut t, 3)); + // Point type and vertical type aren't fitted. + let mut p = point(text, style(12.0)); + assert!(!fit_source_lines(db(), &mut p, 9)); +} + #[test] fn indents_and_paragraph_spacing() { let frame = Rect::new(0.0, 0.0, 200.0, 400.0); @@ -454,6 +488,158 @@ fn on_path_effects_orient_glyphs() { assert_eq!(PathEffect::parse("stair step"), Some(PathEffect::StairStep)); } +/// Type on a straight path from (0, 50) to (500, 50), one run per style: how far each glyph's +/// baseline lies below the path under each Align to Path option, or with `ink` the middle of its +/// drawn outline. +fn path_align_drops(db: &FontDb, styles: &[CharStyle], vertical: bool, ink: bool) -> Vec<(vectorcraft_doc::PathAlign, Vec)> { + use vectorcraft_doc::PathAlign; + let mut line = BezPath::new(); + line.move_to((0.0, 50.0)); + line.line_to((500.0, 50.0)); + let mut t = point("", style(12.0)); + t.runs = styles.iter().map(|st| TextRun::new("x", st.clone())).collect(); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + t.vertical = vertical; + [PathAlign::Baseline, PathAlign::Ascender, PathAlign::Center, PathAlign::Descender] + .into_iter() + .map(|align| { + t.path_align = align; + let drop = |g: &PositionedGlyph| { + let b = g.outline.bounding_box(); + if ink { (b.y0 + b.y1) * 0.5 - 50.0 } else { g.origin.y - 50.0 } + }; + (align, layout(db, &t).glyphs.iter().map(drop).collect()) + }) + .collect() +} + +/// Align to Path moves each glyph's baseline off the path by its own face, size and vertical +/// scale: Ascender by the top of an em box centered on the cap height, Center by half the height +/// of the top of the H in whole thousandths of an em, Descender up by the bottom of the font's +/// bounding box. Without an OS/2 cap height, the em box is centered on the lower of the tops of H +/// and O; without an H, Center takes half the OS/2 cap height. A face with no cap height and no H +/// or O takes an em box from 0.12 em below the baseline to 0.88 em above it and, with a bounding +/// box above the baseline, its descent. Leading and horizontal scale do not count. +#[test] +fn on_path_align_runs_each_glyph_by_its_own_metrics() { + use crate::test_fonts::{PATH_ALIGN_BARE_FAMILY, PATH_ALIGN_CAPITAL_FAMILY, PATH_ALIGN_LOWER_FAMILY, PATH_ALIGN_ROMAN_FAMILY, path_align_font}; + use vectorcraft_doc::PathAlign; + let db = FontDb::with_font_dirs(vec![]); + for family in [PATH_ALIGN_ROMAN_FAMILY, PATH_ALIGN_BARE_FAMILY, PATH_ALIGN_LOWER_FAMILY, PATH_ALIGN_CAPITAL_FAMILY] { + db.add_font(path_align_font(family).unwrap()); + } + let bare = db.face(PATH_ALIGN_BARE_FAMILY, "Regular").unwrap(); + let bare_descent = bare.vertical_metrics().1 / bare.units_per_em(); + let st = |family: &str, size: f64| CharStyle { font_family: family.into(), ..style(size) }; + let styles = [ + st(PATH_ALIGN_ROMAN_FAMILY, 20.0), + st(PATH_ALIGN_ROMAN_FAMILY, 40.0), + CharStyle { v_scale: 200.0, ..st(PATH_ALIGN_ROMAN_FAMILY, 20.0) }, + CharStyle { h_scale: 200.0, leading: Some(90.0), ..st(PATH_ALIGN_ROMAN_FAMILY, 20.0) }, + st(PATH_ALIGN_BARE_FAMILY, 20.0), + st(PATH_ALIGN_LOWER_FAMILY, 20.0), + st(PATH_ALIGN_CAPITAL_FAMILY, 20.0), + ]; + // Per em: the cap height is 0.66 em, the H 0.656 em tall, the bounding box's bottom -0.295 em; + // the lower face's H is 0.668 em tall and its O 0.656 em; the capital face's em box runs from + // its `BASE` table's `ideo` at -0.15 em. + for (align, drops) in path_align_drops(&db, &styles, false, false) { + let (roman, bare, lower, capital) = match align { + PathAlign::Baseline => (0.0, 0.0, 0.0, 0.0), + PathAlign::Ascender => ((0.66 + 1.0) / 2.0, 0.88, (0.656 + 1.0) / 2.0, 0.85), + PathAlign::Center => (0.328, 0.38, 0.334, 0.33), + PathAlign::Descender => (-0.295, -bare_descent, -0.295, -0.295), + }; + let want = [roman * 20.0, roman * 40.0, roman * 40.0, roman * 20.0, bare * 20.0, lower * 20.0, capital * 20.0]; + assert!(drops.len() == 7 && drops.iter().zip(want).all(|(d, w)| (d - w).abs() < 1e-6), "{align:?}: {drops:?}, want {want:?}"); + } +} + +/// The em box whose top Ascender runs along the path, by the kind of face: a `BASE` table's +/// ideographic baseline, unless its roman baseline lies below it; in a face with a Chinese, +/// Japanese or Korean code page, a typographic box that holds the ink of 永 and is one em tall or +/// leaves margins within 197/65,536 em of each other, else a ┼ one em tall, else an em box +/// centered on the ink of 永; else 0.88 em. Center measures the H, not the OS/2 cap height (0.7 em +/// in the heights face). +#[test] +fn on_path_align_finds_the_em_box_of_each_kind_of_face() { + use crate::test_fonts::{ + HEIGHT_FAMILY, PATH_ALIGN_BASE_FAMILY, PATH_ALIGN_CROSS_FAMILY, PATH_ALIGN_DAMAGED_FAMILY, PATH_ALIGN_HHEA_FAMILY, + PATH_ALIGN_IDEOGRAPH_FAMILY, PATH_ALIGN_MARGINS_FAMILY, PATH_ALIGN_NONE_FAMILY, PATH_ALIGN_TYPO_FAMILY, PATH_ALIGN_UNEVEN_FAMILY, + height_font, path_align_font, + }; + use vectorcraft_doc::PathAlign; + let db = FontDb::with_font_dirs(vec![]); + let families = [ + PATH_ALIGN_BASE_FAMILY, + PATH_ALIGN_IDEOGRAPH_FAMILY, + PATH_ALIGN_CROSS_FAMILY, + PATH_ALIGN_TYPO_FAMILY, + PATH_ALIGN_HHEA_FAMILY, + PATH_ALIGN_NONE_FAMILY, + PATH_ALIGN_MARGINS_FAMILY, + PATH_ALIGN_UNEVEN_FAMILY, + PATH_ALIGN_DAMAGED_FAMILY, + ]; + for family in families { + db.add_font(path_align_font(family).unwrap()); + } + db.add_font(height_font(true).unwrap()); + let styles: Vec = families.iter().chain([&HEIGHT_FAMILY]).map(|f| CharStyle { font_family: (*f).into(), ..style(10.0) }).collect(); + let drops = path_align_drops(&db, &styles, false, false); + let of = |align: PathAlign| drops.iter().find(|(a, _)| *a == align).map(|(_, d)| d.clone()).unwrap(); + // BASE -150 + one em; Japanese: the ⌠'s ink -51 to 949 centered, the | from -250 to 750, the + // typographic box -100 to 900, the `hhea` box -100 to 900 under an empty typographic box, + // nothing, the typographic box -101 to 1,002 (margins 3/1,000 em apart), the ink centered + // under a box -101 to 1,003 (4/1,000 em apart); a damaged BASE table's face by its cap height + // 660; the heights face's BASE `idtp` 880. + let want = [0.85, 0.949, 0.75, 0.9, 0.9, 0.88, 1.002, 0.949, 0.83, 0.88].map(|em| em * 10.0); + let ascender = of(PathAlign::Ascender); + assert!(ascender.len() == 10 && ascender.iter().zip(want).all(|(d, w)| (d - w).abs() < 1e-6), "{ascender:?}, want {want:?}"); + let center = of(PathAlign::Center); + assert!((center[9] - 3.28).abs() < 1e-6, "{center:?}"); +} + +/// A variable font's instance takes its em box from the typographic box its `MVAR` table varies: +/// from -100 to 900 in Regular, from 0 to 1,000 in Bold. +#[test] +fn on_path_align_takes_a_variable_font_instances_em_box() { + use crate::test_fonts::{PATH_ALIGN_VARIABLE_FAMILY, path_align_font}; + use vectorcraft_doc::PathAlign; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(path_align_font(PATH_ALIGN_VARIABLE_FAMILY).unwrap()); + let st = |font_style: &str| CharStyle { font_family: PATH_ALIGN_VARIABLE_FAMILY.into(), font_style: font_style.into(), ..style(10.0) }; + let drops = path_align_drops(&db, &[st("Regular"), st("Bold")], false, false); + let ascender = drops.iter().find(|(a, _)| *a == PathAlign::Ascender).map(|(_, d)| d.clone()).unwrap(); + assert!(ascender.len() == 2 && (ascender[0] - 9.0).abs() < 1e-6 && (ascender[1] - 10.0).abs() < 1e-6, "{ascender:?}"); +} + +/// In vertical type, Align to Path moves the glyphs half an em, with their horizontal scale and not +/// their vertical scale, for Ascender and Descender, and leaves them on the path for Center: their +/// baselines and their drawn outlines both. +#[test] +fn on_path_align_in_vertical_type_moves_half_an_em() { + use crate::test_fonts::{PATH_ALIGN_ROMAN_FAMILY, path_align_font}; + use vectorcraft_doc::PathAlign; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(path_align_font(PATH_ALIGN_ROMAN_FAMILY).unwrap()); + let st = CharStyle { font_family: PATH_ALIGN_ROMAN_FAMILY.into(), ..style(20.0) }; + let styles = [st.clone(), CharStyle { h_scale: 200.0, ..st.clone() }, CharStyle { v_scale: 200.0, ..st }]; + for ink in [false, true] { + let baseline = path_align_drops(&db, &styles, true, ink).first().map(|(_, d)| d.clone()).unwrap(); + for (align, drops) in path_align_drops(&db, &styles, true, ink) { + let half = match align { + PathAlign::Ascender => 10.0, + PathAlign::Descender => -10.0, + _ => 0.0, + }; + let want = [half, half * 2.0, half]; + let moved: Vec = drops.iter().zip(&baseline).map(|(d, b)| d - b).collect(); + assert!(moved.len() == 3 && moved.iter().zip(want).all(|(m, w)| (m - w).abs() < 1e-6), "{align:?}, ink {ink}: {moved:?}, want {want:?}"); + } + } +} + #[test] fn on_path_circle_and_overflow() { let circle = kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.1); @@ -727,12 +913,67 @@ fn a_leading_tab_takes_its_width_in_the_line_and_the_justification() { } } +/// Right-aligned area type with tabs: the text before the last tab sits at its stops, measured from +/// the frame's left edge, and the text after the last tab against the right edge, as Illustrator's +/// own layout in a user's `.ai` (used locally, not committed) sets a list of an item and two +/// prices: with a right stop at 269.29 and a left one at 272.13, the item starts at 0, the first +/// price ends at 269.29 and the second ends on the edge. +#[test] +fn right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge() { + use vectorcraft_doc::{TabAlign, TabStop}; + let stop = |position: f64, align: TabAlign| TabStop { position, align, leader: String::new(), align_on: '.' }; + let frame = Rect::new(0.0, 0.0, 336.0, 100.0); + let mut t = area("Item\t12.50\t24.00", style(13.0), frame, Justify::Right); + t.para.tabs = vec![stop(269.29, TabAlign::Right), stop(272.13, TabAlign::Left)]; + let lay = layout(db(), &t); + let glyph = |byte: usize| lay.glyphs.iter().find(|g| g.byte == byte).unwrap(); + let end = |byte: usize| glyph(byte).origin.x + glyph(byte).advance; + assert!(glyph(0).origin.x.abs() < 1e-6, "the item starts at the left edge: {}", glyph(0).origin.x); + let first_price_end = "Item\t12.50".len() - 1; + assert!((end(first_price_end) - 269.29).abs() < 1e-6, "the first price ends on its right stop: {}", end(first_price_end)); + let last = t.plain_text().len() - 1; + assert!((end(last) - 336.0).abs() < 1e-6, "the last price ends on the edge: {}", end(last)); + // One tab: the text after it is against the edge, whatever its stop. + let mut one = area("Another item\t8.00", style(13.0), frame, Justify::Right); + one.para.tabs = t.para.tabs.clone(); + let lay = layout(db(), &one); + let first = lay.glyphs.first().unwrap(); + let last = lay.glyphs.last().unwrap(); + assert!( + first.origin.x.abs() < 1e-6 && (last.origin.x + last.advance - 336.0).abs() < 1e-6, + "{} {}", + first.origin.x, + last.origin.x + last.advance + ); + // A tab ending the line (a heading in the user's file): the text before it at the left edge, + // the tab to the right edge. + let lay = layout(db(), &area("Heading\t\nNext", style(13.0), frame, Justify::Right)); + assert!(lay.glyphs.first().unwrap().origin.x.abs() < 1e-6, "{}", lay.glyphs.first().unwrap().origin.x); + let tab = lay.glyphs.iter().find(|g| g.byte == 7).unwrap(); + assert!((tab.origin.x + tab.advance - 336.0).abs() < 1e-6, "the tab ends at {}", tab.origin.x + tab.advance); + // Text after the last tab that breaks with a hyphen: the hyphen ends on the edge too. + let text = "A\tinternationalization"; + let mut hyphenated = area(text, style(13.0), Rect::new(0.0, 0.0, 100.0, 200.0), Justify::Right); + hyphenated.para.hyphenate = true; + hyphenated.para.composer = vectorcraft_doc::Composer::SingleLine; + let lay = layout(db(), &hyphenated); + let line = &lay.lines[0]; + assert!(line.end > 2 && line.end < text.len(), "the first line breaks inside the word: {line:?}"); + let last = lay.glyphs.get(line.glyph_end - 1).unwrap(); + assert!((last.origin.x + last.advance - 100.0).abs() < 1e-6, "the hyphen ends at {}", last.origin.x + last.advance); + // Without a tab, the line is right aligned as a whole. + let lay = layout(db(), &area("Name", style(13.0), frame, Justify::Right)); + assert!(lay.glyphs.first().unwrap().origin.x > 200.0); +} + #[test] fn a_centred_or_right_aligned_line_sets_its_tabs_as_if_it_started_at_the_left() { use vectorcraft_doc::{TabAlign, TabStop}; // "AB" with a stop at 100 pt is 100 pt plus B wide whatever the alignment: the whole line // moves, the gap between A and B stays 100 pt, and a right aligned line ends on the edge (#2457). - for justify in [Justify::Center, Justify::Right, Justify::JustifyCenter, Justify::JustifyRight] { + // (Right aligned area type sets the text after its last tab against the edge instead: see + // `right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`.) + for justify in [Justify::Center, Justify::JustifyCenter, Justify::JustifyRight] { let mut t = area("A\tB", style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), justify); t.para.tabs = vec![TabStop { position: 100.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; let lay = layout(db(), &t); @@ -1202,6 +1443,157 @@ fn a_right_to_left_numbered_paragraph_has_its_period_left_of_its_number() { assert!((last.origin.x + last.advance - 200.0).abs() < 1e-6, "the number ends the line at the right: {last:?}"); } +/// Bullets and Numbering › Alignment: Left puts a list paragraph's marker at the first line's +/// start, and Right puts it against the text, which starts at the same place with either. Numbers +/// of different widths aligned right end at the same place, the caret before the first character +/// stands at the text, and the lines the paragraph wraps to start at the left indent. A number +/// wider than the room before the left indent takes the text to the next tab stop and ends there. +/// In a right-to-left paragraph, a marker aligned right starts where the text ends. +#[test] +fn a_list_marker_aligned_right_ends_where_the_text_starts() { + use vectorcraft_doc::text::{Bullet, ListAlign, NumberStyle, ParaList}; + // Each marker's characters' left and right edges, and where its line's text starts. + let spans = |t: &TextObject| -> Vec<(f64, f64, f64)> { + let lay = layout(db(), t); + let mut out = vec![]; + for m in &lay.markers { + let chars: Vec = m.glyphs.clone().filter(|&i| i != m.tab).collect(); + let left = chars.iter().map(|&i| lay.glyphs[i].origin.x).fold(f64::MAX, f64::min); + let right = chars.iter().map(|&i| lay.glyphs[i].origin.x + lay.glyphs[i].advance).fold(f64::MIN, f64::max); + let line = &lay.lines[lay.glyphs[m.tab].line]; + let text = lay.glyphs[line.glyph_start..line.glyph_end].iter().find(|g| g.len > 0).unwrap(); + out.push((left, right, text.origin.x)); + } + out + }; + let numbered = |align: ListAlign, count: u32| { + let mut t = listed("One\nTwo", &[Some(ParaList::numbered(NumberStyle::Arabic).aligned(align)); 2]); + t.list_count = count; + t + }; + // "9." and "10.": aligned left, both start at the first-line indent (0); aligned right, both end + // at the left indent (18), where the text starts either way. + let left = spans(&numbered(ListAlign::Left, 8)); + let right = spans(&numbered(ListAlign::Right, 8)); + assert_eq!((left.len(), right.len()), (2, 2)); + for (k, ((l0, r0, text0), (l, r, text))) in left.iter().zip(&right).enumerate() { + assert!(l0.abs() < 1e-6 && (text0 - 18.0).abs() < 1e-6, "item {k} aligned left: {l0} {text0}"); + assert!((r - 18.0).abs() < 1e-6 && (text - text0).abs() < 1e-6, "item {k} aligned right ends at the text: {r} {text}"); + assert!(((r - l) - (r0 - l0)).abs() < 1e-6, "item {k} keeps its width"); + } + assert!(right[1].0 < right[0].0, "the wider number starts further left: {right:?}"); + let t = numbered(ListAlign::Right, 0); + let lay = layout(db(), &t); + let m = &lay.markers[0]; + assert_eq!((m.tab, lay.marker_text(m.tab), lay.marker_text(m.glyphs.start + 1)), (m.glyphs.start, Some("\t"), Some("1."))); + let (top, _) = caret_position(&lay, 0); + assert!((top.x - 18.0).abs() < 1e-6, "the caret before the first character: {top:?}"); + // A wrapped paragraph's other lines start at the left indent. + let long = listed(LOREM, &[Some(ParaList::bulleted(Bullet::Dot).aligned(ListAlign::Right))]); + let lay = layout(db(), &long); + assert!(lay.lines.len() > 2, "the paragraph wraps"); + for (i, line) in lay.lines.iter().enumerate() { + let first = lay.glyphs[line.glyph_start..line.glyph_end].iter().find(|g| g.len > 0).unwrap(); + assert!((first.origin.x - 18.0).abs() < 1e-6, "line {i} starts at {}", first.origin.x); + } + let bullet = &spans(&long)[0]; + assert!((bullet.1 - 18.0).abs() < 1e-6 && bullet.0 > 0.0, "{bullet:?}"); + // "1000." is wider than the 18 pt before the left indent: the text and the number's end go to + // the next default stop. + let mut wide = listed("One", &[Some(ParaList::numbered(NumberStyle::ThreeDigits).aligned(ListAlign::Right))]); + wide.list_count = 999; + let (_, end, text) = spans(&wide)[0]; + assert!((text - 36.0).abs() < 1e-6 && (end - 36.0).abs() < 1e-6, "{end} {text}"); + // Right to left: aligned left the bullet ends the line at the right; aligned right it starts + // where the text ends, and the text ends where it does aligned left. + let rtl = |align: ListAlign| { + let mut t = listed("שלום עולם", &[Some(ParaList::bulleted(Bullet::Dot).aligned(align))]); + t.edit_paras(None, |p| p.justify = Justify::Auto); + let lay = layout(db(), &t); + let m = &lay.markers[0]; + let bullet = m.glyphs.clone().find(|&i| i != m.tab).map(|i| (lay.glyphs[i].origin.x, lay.glyphs[i].origin.x + lay.glyphs[i].advance)); + let text_right = lay.glyphs.iter().filter(|g| g.len > 0).map(|g| g.origin.x + g.advance).fold(0.0, f64::max); + (bullet.unwrap(), text_right) + }; + let ((_, end), text_left_aligned) = rtl(ListAlign::Left); + assert!((end - 200.0).abs() < 1e-6, "{end}"); + let ((start, _), text_right_aligned) = rtl(ListAlign::Right); + assert!((start - text_right_aligned).abs() < 1e-6 && (text_right_aligned - text_left_aligned).abs() < 1e-6, "{start} {text_right_aligned}"); +} + +/// A list paragraph breaks its lines where the same paragraph without a marker, its text at the +/// same indent, breaks them: with either marker alignment, a bullet or a letter, and either +/// composer, the marker and its gap count at their own width and the first word hyphenates. +#[test] +fn a_list_paragraph_breaks_its_lines_as_its_text_alone_does() { + use vectorcraft_doc::text::{Bullet, ListAlign, NumberStyle, ParaList}; + let text = "Extraordinarily comprehensive typographical considerations notwithstanding everything."; + // Each line's end, and whether it ends with a hyphen. + let breaks = |list: Option, composer: vectorcraft_doc::Composer| { + let mut t = area(text, style(14.0), Rect::new(0.0, 0.0, 98.0, 600.0), Justify::Left); + t.para.left_indent = 18.0; + t.para.first_line_indent = if list.is_some() { -18.0 } else { 0.0 }; + t.para.hyphenate = true; + t.para.composer = composer; + t.para.list = list; + let lay = layout(db(), &t); + let hyphen = |k: usize| lay.glyphs[k].len == 0 && lay.marker_of(k).is_none(); + lay.lines.iter().map(|l| (l.end, (l.glyph_start..l.glyph_end).any(hyphen))).collect::>() + }; + for composer in [vectorcraft_doc::Composer::SingleLine, vectorcraft_doc::Composer::EveryLine] { + let alone = breaks(None, composer); + assert!(alone.first().is_some_and(|&(_, hyphen)| hyphen), "the first word hyphenates: {alone:?}"); + for list in [ParaList::bulleted(Bullet::Dot), ParaList::numbered(NumberStyle::UpperLetters)] { + for align in [ListAlign::Left, ListAlign::Right] { + assert_eq!(breaks(Some(list.aligned(align)), composer), alone, "{composer:?} {list:?} {align:?}"); + } + } + } +} + +/// List levels: a paragraph at a level past the first is set 18 pt further in for each level, its +/// marker, its text and the lines it wraps to; each level counts its own numbers, a deeper level +/// starting at 1 and a shallower one going on with its count, from the counts the earlier frames of +/// a thread carry. A right-to-left list paragraph is set that much further in from the right. +#[test] +fn a_list_paragraph_at_a_deeper_level_is_set_further_in_and_counts_on_its_own() { + use vectorcraft_doc::text::{Bullet, ListCounts, NumberStyle, ParaList}; + let num = |level| Some(ParaList::numbered(NumberStyle::Arabic).at_level(level)); + let t = listed("One\nTwo\nThree\nFour\nFive\nSix\nSeven", &[num(1), num(2), num(2), num(3), num(2), num(1), num(2)]); + assert_eq!(marker_texts(&t), ["1.", "1.", "2.", "1.", "3.", "2.", "1."]); + let lay = layout(db(), &t); + for (k, (m, level)) in lay.markers.iter().zip([1, 2, 2, 3, 2, 1, 2]).enumerate() { + let number = &lay.glyphs[m.glyphs.start]; + let line = &lay.lines[number.line]; + let text = lay.glyphs[line.glyph_start..line.glyph_end].iter().find(|g| g.len > 0).unwrap(); + let step = 18.0 * f64::from(level - 1); + assert!((number.origin.x - step).abs() < 1e-6, "item {k}: the number starts at {}", number.origin.x); + assert!((text.origin.x - 18.0 - step).abs() < 1e-6, "item {k}: the text starts at {}", text.origin.x); + } + // The counts the earlier frames carry, per level. + let mut t = listed("Two\nThree\nFour", &[num(2), num(3), num(1)]); + t.set_list_counts(ListCounts([4, 6, 0, 0, 0, 0, 0, 0, 0])); + assert_eq!(marker_texts(&t), ["7.", "1.", "5."]); + // The lines a deeper paragraph wraps to start at its left indent plus the level's 18 pt. + let long = listed(LOREM, &[Some(ParaList::bulleted(Bullet::Dot).at_level(3))]); + let lay = layout(db(), &long); + assert!(lay.lines.len() > 2, "the paragraph wraps"); + assert!((lay.glyphs[lay.markers[0].glyphs.start].origin.x - 36.0).abs() < 1e-6); + for (i, line) in lay.lines.iter().enumerate() { + let first = lay.glyphs[line.glyph_start..line.glyph_end].iter().find(|g| g.len > 0).unwrap(); + assert!((first.origin.x - 54.0).abs() < 1e-6, "line {i} starts at {}", first.origin.x); + } + // Right to left: the bullet at level 2 ends 18 pt before the right edge. + let rtl = |level| { + let mut t = listed("שלום עולם", &[Some(ParaList::bulleted(Bullet::Dot).at_level(level))]); + t.edit_paras(None, |p| p.justify = Justify::Auto); + let lay = layout(db(), &t); + let m = &lay.markers[0]; + m.glyphs.clone().find(|&i| i != m.tab).map(|i| lay.glyphs[i].origin.x + lay.glyphs[i].advance).unwrap() + }; + assert!((rtl(1) - 200.0).abs() < 1e-6 && (rtl(2) - 182.0).abs() < 1e-6, "{} {}", rtl(1), rtl(2)); +} + /// Are the Japanese craft-fonts faces built in? Tests of Japanese glyphs skip (and say so) when /// they aren't: the bundled fonts have none, and [`db`] reads no system fonts. fn japanese_fonts() -> bool { diff --git a/crates/text/src/tests/composite.rs b/crates/text/src/tests/composite.rs new file mode 100644 index 000000000..729afa0d5 --- /dev/null +++ b/crates/text/src/tests/composite.rs @@ -0,0 +1,203 @@ +//! Composite fonts: each class of character set in its part's font. + +use crate::*; +use vectorcraft_doc::{CharStyle, CompositeFont, TextRun}; + +/// Base (Kanji) Source Sans 3; Roman in JetBrains Mono at half size, raised by a tenth of the size; +/// Numerals in Inter, scaled to 80% across; Kana scaled to 50% up from the center. +fn mixed(name: &str) -> CompositeFont { + let mut f = CompositeFont::new(name, "Source Sans 3", "Regular"); + f.parts.roman.font = "JetBrains Mono".into(); + f.parts.roman.size = 50.0; + f.parts.roman.baseline = 10.0; + f.parts.numerals.font = "Inter".into(); + f.parts.numerals.h_scale = 80.0; + f.parts.kana.v_scale = 50.0; + f.parts.kana.scale_at_center = true; + f +} + +fn style(family: &str, size: f64) -> CharStyle { + CharStyle { font_family: family.into(), size, ..CharStyle::default() } +} + +fn face_id(db: &FontDb, family: &str) -> u32 { + db.face(family, "Regular").unwrap().id() +} + +/// Each run in a composite font splits where the class changes; a grapheme cluster stays with its +/// first character, runs in other fonts are kept, and each piece remembers its run. +#[test] +fn composite_runs_split_by_class() { + let db = FontDb::with_font_dirs(vec![]); + db.set_composite_font(&mixed("Mixed")); + let mut t = TextObject::point(Point::ZERO, "Ae\u{301}12\u{304B}\u{3099}国", style("Mixed", 20.0)); + t.runs.insert(0, TextRun::new("x", style("Inter", 12.0))); + let (split, from) = db.composite_split(&t).expect("split"); + let pieces: Vec<(&str, &str)> = split.runs.iter().map(|r| (r.text.as_str(), r.style.font_family.as_str())).collect(); + assert_eq!( + pieces, + [("x", "Inter"), ("Ae\u{301}", "JetBrains Mono"), ("12", "Inter"), ("\u{304B}\u{3099}", "Source Sans 3"), ("国", "Source Sans 3")] + ); + assert_eq!(from, [0, 1, 1, 1, 1]); + assert_eq!(split.plain_text(), t.plain_text()); + let roman = &split.runs[1].style; + assert_eq!((roman.size, roman.baseline_shift, roman.h_scale, roman.v_scale), (10.0, 2.0, 100.0, 100.0)); + let numerals = &split.runs[2].style; + assert_eq!((numerals.size, numerals.h_scale), (20.0, 80.0)); + // Kana at half height, raised by half the em box's center so the center stays put. + let kana = &split.runs[3].style; + let centre = db.face("Source Sans 3", "Regular").unwrap().ideographic_centre(); + assert_eq!(kana.v_scale, 50.0); + assert!((kana.baseline_shift - 20.0 * centre * 0.5).abs() < 1e-9, "{}", kana.baseline_shift); + assert_eq!(split.runs[4].style.baseline_shift, 0.0); + // Text in no composite font is not split. + assert!(db.composite_split(&TextObject::point(Point::ZERO, "Ab", style("Inter", 12.0))).is_none()); +} + +/// Laid out, each character is drawn in its class's font, sized by its part, and the glyphs still +/// name the run of the text they belong to. +#[test] +fn composite_type_draws_each_class_in_its_font() { + let db = FontDb::with_font_dirs(vec![]); + db.set_composite_font(&mixed("Mixed")); + let mut t = TextObject::point(Point::ZERO, "x", style("Inter", 12.0)); + t.runs.push(TextRun::new("A1", style("Mixed", 20.0))); + let l = layout(&db, &t); + let fonts: Vec<(usize, u32)> = l.glyphs.iter().map(|g| (g.run, g.font_id)).collect(); + assert_eq!(fonts, [(0, face_id(&db, "Inter")), (1, face_id(&db, "JetBrains Mono")), (1, face_id(&db, "Inter"))]); + // `A` is set as JetBrains Mono at 10 pt would set it. + let alone = layout(&db, &TextObject::point(Point::ZERO, "A", style("JetBrains Mono", 10.0))); + assert!((l.glyphs[1].advance - alone.glyphs[0].advance).abs() < 1e-9); +} + +/// A ruby reading over type in a composite font is set in the parts its own characters fall in. +#[test] +fn ruby_over_composite_type_is_set_in_its_own_parts() { + let db = FontDb::with_font_dirs(vec![]); + db.set_composite_font(&mixed("Ruby Parts")); + let mut t = TextObject::point(Point::ZERO, "\u{56FD}", style("Ruby Parts", 20.0)); + t.ruby = vec![vectorcraft_doc::text::RubySpan::new(0, 3, "A")]; + let l = layout(&db, &t); + let fonts: Vec = l.ruby.iter().map(|g| g.font_id).collect(); + assert_eq!(fonts, [face_id(&db, "JetBrains Mono")]); + assert_eq!(l.ruby[0].run, 0); + // Over a Roman base (a 50% part), the reading is half the 20 pt type, scaled once by its part: + // 5 pt, not the base's 10 pt halved twice. + let mut t = TextObject::point(Point::ZERO, "A", style("Ruby Parts", 20.0)); + t.ruby = vec![vectorcraft_doc::text::RubySpan::new(0, 1, "a")]; + let l = layout(&db, &t); + let alone = layout(&db, &TextObject::point(Point::ZERO, "a", style("JetBrains Mono", 5.0))); + assert_eq!(l.ruby.len(), 1); + // The width each glyph is drawn at, in text space. + let width = |g: &crate::PositionedGlyph| { + use kurbo::Shape; + g.outline.bounding_box().width() + }; + let (reading, expected) = (width(&l.ruby[0]), width(&alone.glyphs[0])); + assert!(expected > 0.0 && (reading - expected).abs() < 1e-6, "{reading} vs {expected}"); + // It sits above the base as drawn (10 pt), not above the 20 pt the text sets it in. + let rise = l.glyphs[0].origin.y - l.ruby[0].origin.y; + assert!(rise > 8.0 && rise < 15.0, "the reading is {rise} pt above the base's baseline"); +} + +/// Lent composite fonts are available while a guard lends them: a second guard lends one again, a +/// font of the same name the database holds is replaced and put back afterwards, and a font named +/// like an installed family is not lent. +#[test] +fn lent_composite_fonts_last_as_long_as_a_guard() { + let db = FontDb::with_font_dirs(vec![]); + db.set_composite_font(&mixed("Held")); + let mut held = mixed("Held"); + held.parts.roman.size = 75.0; + let fonts = [mixed("Lent"), held, CompositeFont::new("Inter", "Source Sans 3", "Regular")]; + let first = db.lend_composite_fonts(&fonts); + assert!(db.composite_font("lent").is_some()); + assert_eq!(db.composite_font("Held").unwrap().parts.roman.size, 75.0); + assert!(db.composite_font("Inter").is_none()); + let second = db.lend_composite_fonts(&fonts); + drop(first); + assert!(db.composite_font("Lent").is_some()); + assert_eq!(db.composite_font("Held").unwrap().parts.roman.size, 75.0); + drop(second); + assert!(db.composite_font("Lent").is_none()); + assert_eq!(db.composite_font("Held").unwrap().parts.roman.size, 50.0); + // A font set in its place while lent stays when the guard drops. + let lent = db.lend_composite_fonts(&fonts[..1]); + let mut replaced = mixed("Lent"); + replaced.parts.roman.size = 60.0; + db.set_composite_font(&replaced); + drop(lent); + assert_eq!(db.composite_font("Lent").unwrap().parts.roman.size, 60.0); + // So does the same definition set while lent, after every guard lending it drops. + assert!(db.remove_composite_font("Lent")); + let (one, two) = (db.lend_composite_fonts(&fonts[..1]), db.lend_composite_fonts(&fonts[..1])); + assert!(!db.set_composite_font(&fonts[0])); + drop(one); + drop(two); + assert!(db.composite_font("Lent").is_some()); + // A guard whose font was removed doesn't give back a later guard's loan of that name. + assert!(db.remove_composite_font("Lent")); + let old = db.lend_composite_fonts(&fonts[..1]); + assert!(db.remove_composite_font("Lent")); + let new = db.lend_composite_fonts(&fonts[..1]); + drop(old); + assert!(db.composite_font("Lent").is_some()); + drop(new); + assert!(db.composite_font("Lent").is_none()); +} + +/// The fonts a composite font sets text in are those of the parts its characters fall in, and +/// coverage is checked against each character's part. +#[test] +fn composite_fonts_for_text_follow_each_characters_part() { + let db = FontDb::with_font_dirs(vec![]); + let mut f = mixed("Parts"); + f.parts.kana.font = "No Such Kana Font".into(); + db.set_composite_font(&f); + let fonts = |text: &str| db.composite_fonts_for("parts", text).unwrap(); + let pair = |family: &str| (family.to_string(), "Regular".to_string()); + assert_eq!(fonts("A1"), [pair("JetBrains Mono"), pair("Inter")]); + assert_eq!(fonts(""), [pair("Source Sans 3")]); + assert!(db.composite_fonts_for("Inter", "A").is_none()); + let exact = db.composite_coverage("Parts", &["A1"]).unwrap(); + assert_eq!((exact.status, exact.lacking.len()), (FontMatch::Exact, 0)); + let missing = db.composite_coverage("Parts", &["A\u{304B}"]).unwrap(); + assert_eq!(missing.status, FontMatch::Missing); + assert_eq!(missing.face.map(|f| f.family.clone()).as_deref(), Some(FALLBACK_FAMILY)); + // Each run is split on its own: a combining mark that starts a run is a Roman character. + f.parts.kana.font = "Source Sans 3".into(); + f.parts.roman.font = "No Such Roman Font".into(); + db.set_composite_font(&f); + assert_eq!(db.composite_coverage("Parts", &["\u{304B}\u{301}"]).unwrap().status, FontMatch::Exact); + assert_eq!(db.composite_coverage("Parts", &["\u{304B}", "\u{301}"]).unwrap().status, FontMatch::Missing); + assert_eq!(db.stored_family("parts", "Regular").as_deref(), Some("Parts")); + assert_eq!(db.stored_family("No Such Kana Font", "Regular"), None); +} + +/// A composite font resolves to its base font as an exact match, lists first in the font menus, +/// and is gone from them once removed. +#[test] +fn composite_fonts_resolve_to_their_base_and_list_first() { + let db = FontDb::with_font_dirs(vec![]); + let generation = db.generation(); + db.set_composite_font(&mixed("Zeta Mixed")); + assert_ne!(db.generation(), generation); + let (face, how) = db.resolve("zeta mixed", "Regular").unwrap(); + assert_eq!((face.family.as_str(), how), ("Source Sans 3", FontMatch::Exact)); + assert!(db.has_family("Zeta Mixed")); + assert_eq!(db.menu_family_list().first().map(String::as_str), Some("Zeta Mixed")); + // Registering the same definition again changes nothing. + let generation = db.generation(); + db.set_composite_font(&mixed("Zeta Mixed")); + assert_eq!(db.generation(), generation); + // A base naming a composite font falls back rather than looping. + let mut odd = mixed("Odd"); + odd.parts.kanji.font = "Zeta Mixed".into(); + db.set_composite_font(&odd); + assert_eq!(db.face("Odd", "Regular").unwrap().family, FALLBACK_FAMILY); + assert!(db.remove_composite_font("ZETA MIXED")); + assert!(!db.remove_composite_font("Zeta Mixed")); + assert!(!db.menu_family_list().iter().any(|f| f == "Zeta Mixed")); + assert!(!db.has_family("Zeta Mixed")); +} diff --git a/crates/text/src/tests/inline.rs b/crates/text/src/tests/inline.rs index d492608cc..76c90e65b 100644 --- a/crates/text/src/tests/inline.rs +++ b/crates/text/src/tests/inline.rs @@ -347,6 +347,25 @@ fn top_to_top_leading_spaces_inline_art_by_its_run() { assert!((small0 - tall0).abs() < 1e-6 && (small1 - tall1).abs() < 1e-6, "{small0} {small1} / {tall0} {tall1}"); } +/// A pushed-in line closes the gaps next to its CJK characters and leaves the gap after inline art +/// open. Needs a font with full-width Japanese (skipped without one). +#[test] +fn a_pushed_in_line_leaves_the_gap_after_inline_art() { + let mut t = TextObject::point(Point::ZERO, "一二", style(20.0)); + t.xf = Affine::IDENTITY; + t.runs.push(TextRun::inline(art(0.5), style(20.0))); + t.runs.push(TextRun::new("四五六七八九十一二三四五」六", style(20.0))); + t.para.kinsoku_type = vectorcraft_doc::KinsokuShoriType::PushInFirst; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 300.0, 200.0).to_path(0.1)) }; + let l = layout(FontDb::global(), &t); + if (l.glyphs[0].advance - 20.0).abs() > 2.0 { + return; // no font with full-width Japanese here + } + assert!(l.glyphs[1].advance < 20.0 - 0.5, "the other gaps close up: {}", l.glyphs[1].advance); + let art = inline_glyph(&l); + assert!((art.advance - 20.0).abs() < 1e-6, "the art keeps its advance: {}", art.advance); +} + #[test] fn inline_art_moves_with_vertically_aligned_lines() { use vectorcraft_doc::text::{AreaOptions, VerticalAlign}; diff --git a/crates/text/src/tests/ligature_carets.rs b/crates/text/src/tests/ligature_carets.rs index 929ba50bc..f669b07f4 100644 --- a/crates/text/src/tests/ligature_carets.rs +++ b/crates/text/src/tests/ligature_carets.rs @@ -8,7 +8,8 @@ use vectorcraft_geom::PathData; use crate::test_fonts::{ LIGATURE_CARET_FAMILY, LIGATURE_CARET_GLYPH, VARIABLE_LIGATURE_CARET_FAMILY, VERTICAL_LIGATURE_CARET_FAMILY, lam_alef_family, lam_alef_font, - ligature_caret_font, placed_ligature_caret_font, source_sans_gid, variable_ligature_caret_font, vertical_ligature_caret_font, + ligature_caret_font, placed_ligature_caret_font, proportional_vertical_ligature_caret_font, source_sans_gid, variable_ligature_caret_font, + vertical_ligature_caret_font, }; use crate::*; @@ -628,9 +629,10 @@ fn an_upright_ligature_takes_its_caret_down_its_cell() { assert!((at(900) - 0.5).abs() < 1e-9, "{}", at(900)); } -/// On a path, an upright ligature in vertical type turns about its pen, so its cell runs from -/// 0.12 em before the pen to 0.88 em after it, with or without the font's vertical metrics. The -/// inner stop lies at the middle of that cell, and the first character's selection ends there. +/// On a path, an upright ligature in vertical type stands with its top toward the path's start, and +/// its cell runs from 0.12 em before the pen to 0.88 em after it, with or without the font's vertical +/// metrics. The inner stop lies at the middle of that cell, and the first character's selection ends +/// there. #[test] fn an_upright_ligature_on_a_path_takes_its_stop_in_its_turned_cell() { for (font, family) in [(ligature_caret_font(None), LIGATURE_CARET_FAMILY), (vertical_ligature_caret_font(900), VERTICAL_LIGATURE_CARET_FAMILY)] { @@ -688,6 +690,100 @@ fn an_upright_ligature_on_a_path_takes_its_stop_in_its_turned_cell() { } } +/// On a path, an upright ligature in vertical type reads along the path in order: the top of its +/// cell comes first, and its font caret (600 units down the cell) is drawn where the caret, a click +/// and the selection put the stop, on a path running either way with any path effect. A glyph lying +/// on its side keeps its top toward the path's end. +#[test] +fn an_upright_ligature_on_a_path_reads_along_it_in_order() { + use vectorcraft_doc::PathEffect; + let db = db_with(vertical_ligature_caret_font(280)); + let mut t = point("fi", VERTICAL_LIGATURE_CARET_FAMILY); + t.vertical = true; + t.runs[0].style.standard_vertical_roman = true; + for (to, effect) in [ + (1000.0, PathEffect::Rainbow), + (-1000.0, PathEffect::Rainbow), + (-1000.0, PathEffect::Skew), + (-1000.0, PathEffect::Ribbon3d), + (-1000.0, PathEffect::StairStep), + (-1000.0, PathEffect::Gravity), + ] { + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((to, 0.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + t.path_effect = effect; + let l = layout(&db, &t); + let Some(g) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; + let &[(1, f)] = g.carets.as_slice() else { panic!("one inner stop: {:?}", g.carets) }; + assert!((f - 0.6).abs() < 1e-9, "{f}"); + let tangent = Vec2::new(g.angle.cos(), g.angle.sin()); + let along = |p: Point| (p - g.origin).dot(tangent); + // Font units, y down: the cell's top is 880 units above the baseline and its foot 120 below. + let (top, foot) = (along(g.xf * Point::new(0.0, -880.0)), along(g.xf * Point::new(0.0, 120.0))); + assert!(top < foot, "{to} {effect:?}: the top comes first along the path: {top} {foot}"); + let (start, width) = (g.width_start, g.width); + assert!((top - start).abs() < 1e-6 && (foot - top - width).abs() < 1e-6, "{to} {effect:?}: {top} {foot} {start} {width}"); + let drawn = along(g.xf * Point::new(0.0, -280.0)); + assert!((drawn - (top + (foot - top) * 0.6)).abs() < 1e-6, "{to} {effect:?}: {drawn} against {top} {foot}"); + let (a, b) = caret_position(&l, 1); + let caret = along(a.midpoint(b)); + assert!((caret - drawn).abs() < 1e-6, "{to} {effect:?}: the caret stands at the drawn stop: {caret} against {drawn}"); + assert_eq!(hit_byte(&l, g.origin + tangent * (drawn + 0.3)), 1, "{to} {effect:?}"); + assert_eq!(hit_byte(&l, g.origin + tangent * (drawn - 0.3)), 1, "{to} {effect:?}"); + assert_eq!(hit_byte(&l, g.origin + tangent * 0.3), 0, "{to} {effect:?}"); + let q = selection_quads(&l, 0, 1); + assert_eq!(q.len(), 1, "{q:?}"); + let (q0, q1) = (along(q[0][0]), along(q[0][1])); + assert!((q0.min(q1) - drawn.min(0.0)).abs() < 1e-6 && (q0.max(q1) - drawn.max(0.0)).abs() < 1e-6, "{to} {effect:?}: {q:?} against {drawn}"); + } + // Without Standard Vertical Roman Alignment the letter lies on its side: its top still faces + // the path's end and its advance runs across the path, to its right. + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((1000.0, 0.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + t.path_effect = PathEffect::Rainbow; + t.runs[0].style.standard_vertical_roman = false; + let l = layout(&db, &t); + let Some(s) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; + let tangent = Vec2::new(s.angle.cos(), s.angle.sin()); + let right = Vec2::new(-tangent.y, tangent.x); + let (pen, up, ahead) = (s.xf * Point::ZERO, s.xf * Point::new(0.0, -500.0), s.xf * Point::new(500.0, 0.0)); + assert!((up - pen).dot(tangent) > 1.0 && (ahead - pen).dot(right) > 1.0, "{pen:?} {up:?} {ahead:?}"); +} + +/// On a path, an upright ligature whose cell Proportional Metrics shortens (`vpal`, from 1000 units +/// to 800) keeps its font caret (600 units down the cell) where it is drawn: the inner stop lies at +/// 600 / 800 of its width, and the caret stands at the drawn stop. +#[test] +fn an_upright_ligature_on_a_path_keeps_its_font_caret_on_a_proportional_cell() { + use vectorcraft_doc::PathEffect; + let db = db_with(proportional_vertical_ligature_caret_font(280, -200)); + let mut t = point("fi", VERTICAL_LIGATURE_CARET_FAMILY); + t.vertical = true; + t.runs[0].style.standard_vertical_roman = true; + t.runs[0].style.proportional_metrics = true; + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((1000.0, 0.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + t.path_effect = PathEffect::Rainbow; + let l = layout(&db, &t); + let Some(g) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; + assert!((g.width - 80.0).abs() < 1e-6, "the cell is 800 units at 100 pt: {}", g.width); + let &[(1, f)] = g.carets.as_slice() else { panic!("one inner stop: {:?}", g.carets) }; + assert!((f - 0.75).abs() < 1e-9, "{f}"); + let tangent = Vec2::new(g.angle.cos(), g.angle.sin()); + let along = |p: Point| (p - g.origin).dot(tangent); + let (top, drawn) = (along(g.xf * Point::new(0.0, -880.0)), along(g.xf * Point::new(0.0, -280.0))); + assert!((top - g.width_start).abs() < 1e-6 && (drawn - top - 60.0).abs() < 1e-6, "{top} {drawn} {}", g.width_start); + let (a, b) = caret_position(&l, 1); + let caret = along(a.midpoint(b)); + assert!((caret - drawn).abs() < 1e-6, "the caret stands at the drawn stop: {caret} against {drawn}"); +} + /// Tracking that makes an upright ligature's advance negative leaves it upright: it keeps its /// caret down its cell, and its cell starts where it does without tracking, with or without the /// font's vertical metrics. diff --git a/crates/text/src/tests/typo.rs b/crates/text/src/tests/typo.rs index 4791347d0..4b8bdbc09 100644 --- a/crates/text/src/tests/typo.rs +++ b/crates/text/src/tests/typo.rs @@ -1970,14 +1970,28 @@ fn soft_return_breaks_line_without_new_paragraph() { assert_eq!(&t.plain_text()[l1.start..l1.end], "Line 2"); } +/// A line ending in a soft return is justified as a wrapped line is: only the paragraph's last +/// line keeps its alignment. Illustrator's own layout in a user's `.ai` (used locally, not +/// committed) spreads such lines to the frame's edge. #[test] -fn soft_return_in_justified_text_does_not_spread() { - let text = "Short\u{2028}A much longer second line of text inside the frame."; - let t = area(text, style(12.0), Rect::new(0.0, 0.0, 300.0, 200.0), Justify::JustifyLeft); - let lay = layout(db(), &t); - assert!(lay.lines.len() >= 2); - let l0 = &lay.lines[0]; - assert!(l0.x1 - l0.x0 < 100.0, "short line ending with soft return is not justified: {}", l0.x1 - l0.x0); +fn soft_return_in_justified_text_spreads_like_a_wrapped_line() { + let frame = Rect::new(0.0, 0.0, 300.0, 200.0); + let text = "A line of a few words\u{2028}and the last line."; + for justify in [Justify::JustifyLeft, Justify::JustifyCenter, Justify::JustifyRight] { + let lay = layout(db(), &area(text, style(12.0), frame, justify)); + assert_eq!(lay.lines.len(), 2); + let (l0, l1) = (&lay.lines[0], &lay.lines[1]); + assert!((l0.x0 - frame.x0).abs() < 1e-6 && (l0.x1 - frame.x1).abs() < 1e-6, "{justify:?}: {l0:?}"); + assert!(l1.x1 - l1.x0 < 200.0, "{justify:?}: the last line isn't justified: {l1:?}"); + // A soft return ending the paragraph: the empty line after it is the last one, so the line + // it ends is justified too. + let lay = layout(db(), &area("A line of a few words\u{2028}", style(12.0), frame, justify)); + let l0 = lay.lines.first().unwrap(); + assert!((l0.x0 - frame.x0).abs() < 1e-6 && (l0.x1 - frame.x1).abs() < 1e-6, "{justify:?}, trailing soft return: {l0:?}"); + } + // Aligned type stays aligned. + let lay = layout(db(), &area(text, style(12.0), frame, Justify::Left)); + assert!(lay.lines[0].x1 < frame.x1 - 100.0); } #[test] diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index 35e8260da..225395d34 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -855,6 +855,86 @@ 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); +} + +/// 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` diff --git a/crates/text/src/thread.rs b/crates/text/src/thread.rs index 191963107..cfdd41cc7 100644 --- a/crates/text/src/thread.rs +++ b/crates/text/src/thread.rs @@ -4,6 +4,7 @@ //! testing need nothing special; after an edit the engine joins the slices and re-distributes them //! with [`distribute`]. +use vectorcraft_doc::text::ListCounts; use vectorcraft_doc::{ParaStyle, TextObject, TextRun}; use crate::edit::{normalize, runs_len, slice_runs, style_at}; @@ -82,29 +83,38 @@ fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize { } /// A frame's share of a story: its runs, its paragraphs' styles (one per paragraph) and the -/// numbered list paragraphs counted before its first one ([`TextObject::list_count`]). +/// numbered list paragraphs counted before its first one, at level 1 and at the levels past it +/// ([`TextObject::list_count`], [`TextObject::list_level_counts`]). #[derive(Clone, Debug, PartialEq)] pub struct Slice { pub runs: Vec, pub paras: Vec, pub list_count: u32, + pub list_level_counts: Vec, /// The first paragraph continues from the frame before /// ([`TextObject::first_para_continues`]). pub continues: bool, } -/// The count of numbered list paragraphs after the paragraphs of `runs` (styles `paras`, one per -/// paragraph) but the last one, counted on from `count` as the layout counts them +impl Slice { + /// The list counts the slice's frame starts from ([`TextObject::list_counts`]). + pub fn list_counts(&self) -> ListCounts { + ListCounts::from_parts(self.list_count, &self.list_level_counts) + } +} + +/// The counts of numbered list paragraphs at each level after the paragraphs of `runs` (styles +/// `paras`, one per paragraph) but the last one, counted on from `counts` as the layout counts them /// ([`vectorcraft_doc::text::ParaList::next_marker`]). A slice's last paragraph also starts the /// next frame, which counts it. -fn count_list_paragraphs(runs: &[TextRun], paras: &[ParaStyle], mut count: u32) -> u32 { +fn count_list_paragraphs(runs: &[TextRun], paras: &[ParaStyle], mut counts: ListCounts) -> ListCounts { let text: String = runs.iter().map(|r| r.text.as_str()).collect(); let mut pieces: Vec<&str> = text.split('\n').collect(); pieces.pop(); for (piece, para) in pieces.iter().zip(paras) { - vectorcraft_doc::text::ParaList::next_marker(para.list, piece.is_empty(), &mut count); + vectorcraft_doc::text::ParaList::next_marker(para.list, piece.is_empty(), &mut counts); } - count + counts } /// Whether the frame after one whose share ends with byte `before` starts inside a paragraph: it @@ -134,21 +144,36 @@ pub fn continued(frames: &[&TextObject]) -> Vec { /// keep one empty run carrying the style at the split, so typing there has a style. A paragraph /// split between frames has its style in both, and each slice that starts inside a paragraph is /// marked as continuing it ([`Slice::continues`]). Each frame's numbered list paragraphs are counted -/// on from the frames before it. Each frame's share is measured with that mark and that count. +/// on from the frames before it, at each level. Each frame's share is measured with that mark and +/// those counts. pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras: &[ParaStyle]) -> Vec { - distribute_continuing(db, frames, runs, paras, false, 0) + distribute_continuing_levels(db, frames, runs, paras, false, ListCounts::default()) } /// [`distribute`] for `runs` that follow text before them: the first frame's share is measured, /// and marked, as continuing a paragraph when `continued` is set, and its numbered list paragraphs -/// are counted on from `list_count`. +/// at level 1 are counted on from `list_count` ([`distribute_continuing_levels`] takes the counts +/// of every level). pub fn distribute_continuing( db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras: &[ParaStyle], continued: bool, - mut list_count: u32, + list_count: u32, +) -> Vec { + distribute_continuing_levels(db, frames, runs, paras, continued, ListCounts::from_parts(list_count, &[])) +} + +/// [`distribute_continuing`] with the first frame's numbered list paragraphs counted on from +/// `counts` at each level. +pub fn distribute_continuing_levels( + db: &FontDb, + frames: &[&TextObject], + runs: &[TextRun], + paras: &[ParaStyle], + continued: bool, + mut counts: ListCounts, ) -> Vec { let mut rest = runs.to_vec(); let mut out = Vec::with_capacity(frames.len()); @@ -165,7 +190,7 @@ pub fn distribute_continuing( let mut probe = (*f).clone(); probe.runs = rest.clone(); probe.set_paragraph_styles(slice_paras(paras, first_para, &rest)); - probe.list_count = list_count; + probe.set_list_counts(counts); probe.first_para_continues = continues; fit(db, &probe, &rest) }; @@ -177,11 +202,12 @@ pub fn distribute_continuing( rest = if tail.is_empty() { vec![TextRun { text: String::new(), style: style_at(&rest, len), inline: None }] } else { tail }; let head_paras = slice_paras(paras, first_para, &head); next_para += head_paras.len().saturating_sub(1); - let next_count = count_list_paragraphs(&head, &head_paras, list_count); + let next_counts = count_list_paragraphs(&head, &head_paras, counts); let starts_continued = continues; continues = continues_after(head.iter().rev().find_map(|r| r.text.bytes().last()), continues); - out.push(Slice { runs: head, paras: head_paras, list_count, continues: starts_continued }); - list_count = next_count; + let (list_count, list_level_counts) = counts.parts(); + out.push(Slice { runs: head, paras: head_paras, list_count, list_level_counts, continues: starts_continued }); + counts = next_counts; } out } diff --git a/crates/text/src/thread/tests.rs b/crates/text/src/thread/tests.rs index 222df20c2..c3f05cef4 100644 --- a/crates/text/src/thread/tests.rs +++ b/crates/text/src/thread/tests.rs @@ -378,6 +378,40 @@ fn a_list_paragraph_continued_onto_a_path_has_no_bullet_there() { assert_eq!(layout(db, &alone).markers.len(), 1); } +/// List levels: each level's count runs on through the frames, a deeper level in the next +/// frame going on with the count its paragraphs reached in the frame before. +#[test] +fn list_levels_count_on_through_the_frames() { + use vectorcraft_doc::text::{NumberStyle, ParaList}; + let db = FontDb::global(); + let (a, mut b) = (frame(0.0), frame(200.0)); + b.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(200.0, 0.0, 320.0, 400.0)) }; + let at = |level| ParaStyle { + list: Some(ParaList::numbered(NumberStyle::Arabic).at_level(level)), + left_indent: 18.0, + first_line_indent: -18.0, + ..Default::default() + }; + let numbers = |f: &TextObject, s: &Slice| { + let mut t = f.clone(); + t.runs = s.runs.clone(); + t.set_paragraph_styles(s.paras.clone()); + t.set_list_counts(s.list_counts()); + layout(db, &t).markers.iter().map(|m| m.text.clone()).collect::>() + }; + // One item at level 1 and eleven at level 2: 1. then 1. to 11. through both frames. + let paras: Vec = (0..12).map(|i| at(if i == 0 { 1 } else { 2 })).collect(); + let text = (1..=12).map(|i| format!("Item {i}")).collect::>().join("\n"); + let story = vec![TextRun { text, style: CharStyle::default(), inline: None }]; + let parts = distribute(db, &[&a, &b], &story, ¶s); + let first = numbers(&a, &parts[0]); + assert!((2..12).contains(&first.len()), "both frames hold items: {first:?}"); + let both: Vec = [first.clone(), numbers(&b, &parts[1])].concat(); + let expected: Vec = std::iter::once("1.".to_string()).chain((1..=11).map(|n| format!("{n}."))).collect(); + assert_eq!(both, expected); + assert_eq!((parts[1].list_count, parts[1].list_level_counts.clone()), (1, vec![first.len() as u32 - 1])); +} + #[test] fn short_story_leaves_later_frames_empty_with_a_style() { let db = FontDb::global(); diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 43ec2d72a..6ce5f3501 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -11,10 +11,12 @@ mod visibility; pub use solid::{Bevel, BevelShape, Extrude, Facet, Inflate, View, extrude, inflate, inflate_together, plane}; pub use visibility::{VisibleFace, visible_faces}; mod shading; -pub use shading::{LightGradient, RgbGradient, ShadeGradient}; +pub use shading::{ClassicShading, ClassicSurface, LightGradient, RgbGradient, ShadeGradient}; mod lights; pub use lights::{Light, Lights, MAX_LIGHTS}; mod caps; +mod mesh; +pub use mesh::{Mesh, extrude_mesh, inflate_meshes, plane_mesh, revolve_mesh}; use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect, Vec2, kurbo::ParamCurveDeriv}; @@ -447,7 +449,17 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi /// Evaluate a profile in document space. All subpaths share one axis from `bounds`. /// Invalid or excessively complex inputs return an error, never a partial surface. pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result, String> { + revolve_faces(path, bounds, options, None) +} + +/// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn. +fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Option<&mut Vec>) -> Result, String> { let o = options.sanitized(); + // A face kept for a mesh: its normal follows from its corners. + let keep = |mesh: &mut Vec, face: Vec, normal: V3| { + mesh.push((vec![face], normal)); + if mesh.len() > MAX_FACES { Err("Revolve surface is too complex; reduce Segments or simplify the profile".to_string()) } else { Ok(()) } + }; if ![bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { return Err("Revolve requires finite profile coordinates within 100 million points".into()); } @@ -543,6 +555,16 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result = profile.iter().map(|p| (p.x - axis, p.y)).collect(); + if let (false, Some(first), Some(last)) = (sp.closed, profile.first(), profile.last()) { + ring.extend([(0.0, last.y), (0.0, first.y)]); + } + let area: f64 = ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).map(|(p, q)| p.0 * q.1 - q.0 * p.1).sum(); + if area < 0.0 { -1.0 } else { 1.0 } + }; for (i, (pair, [ta, tb])) in profile.windows(2).zip(tangents).enumerate() { let [a, b] = pair else { continue }; if ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { @@ -550,6 +572,14 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result, edges: &[(usize, &caps::RingArc, bool)]| { @@ -581,6 +611,17 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result = (0..=steps).map(|j| vertex(*end, j)).collect(); + if o.angle >= 360.0 { + cap.pop(); + } else { + cap.insert(0, o.rotate(V3(0.0, end.y - center_y, 0.0))); + } + keep(mesh, cap, o.rotate(V3(0.0, outward, 0.0)))?; + continue; + } // Keep the original tessellation work budget, even though each cap // now produces only one curved vector face. emit(ring.face(bounds, o, light, outward)?, steps)?; diff --git a/crates/three-d/src/mesh.rs b/crates/three-d/src/mesh.rs new file mode 100644 index 000000000..2f2f69844 --- /dev/null +++ b/crates/three-d/src/mesh.rs @@ -0,0 +1,372 @@ +//! The solids of the 3D effects as triangle meshes, for files that other 3D apps open (OBJ, glTF, +//! USD). A mesh holds the faces the effect builds before they are projected onto the page: every +//! face, those turned away from the viewer included, at the turn the effect gives the art. +//! +//! Coordinates are points with y up and z toward the viewer, about the point the solid turns about +//! (the centre of the art's bounds; for Revolve, the axis at the profile's middle). Each face keeps +//! its own corners and normal, so the mesh is shaded flat. Planar faces with holes (caps and the +//! turned plane) are cut into triangles: each hole is joined to its outline by a bridge, then the +//! outline is cut by ear clipping. + +use vectorcraft_geom::{FillRule, PathData, Rect}; + +use crate::solid::{Face3, extrude_faces, inflate_faces, plane_faces}; +use crate::{Extrude, Inflate, Revolve, V3, View}; + +/// Most steps the triangulation of one solid may take: a face's ears and bridges each test the +/// face's other corners. +const MAX_WORK: usize = 400_000_000; + +/// A triangle mesh. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Mesh { + /// Corner positions in points: x right, y up, z toward the viewer. + pub positions: Vec<[f64; 3]>, + /// One unit normal for each position: the normal of the face the corner belongs to. + pub normals: Vec<[f64; 3]>, + /// Corner indices, counterclockwise seen from the side the normals point to. + pub triangles: Vec<[u32; 3]>, +} + +impl Mesh { + pub fn is_empty(&self) -> bool { + self.triangles.is_empty() + } + + /// The smallest and largest coordinate on each axis; `None` for an empty mesh. + pub fn bounds(&self) -> Option<([f64; 3], [f64; 3])> { + let first = *self.positions.first()?; + Some(self.positions.iter().fold((first, first), |(lo, hi), p| { + ([lo[0].min(p[0]), lo[1].min(p[1]), lo[2].min(p[2])], [hi[0].max(p[0]), hi[1].max(p[1]), hi[2].max(p[2])]) + })) + } + + /// The meshes of `parts` as one, in order. + pub fn join(parts: impl IntoIterator) -> Mesh { + let mut out = Mesh::default(); + for m in parts { + let base = out.positions.len() as u32; + out.positions.extend(m.positions); + out.normals.extend(m.normals); + out.triangles.extend(m.triangles.into_iter().map(|t| t.map(|i| i + base))); + } + out + } +} + +/// Extrude & Bevel's solid ([`crate::extrude`]) as a mesh. +pub fn extrude_mesh(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result { + extrude_faces(path, rule, bounds, options, view, true).and_then(|(_, faces)| mesh(&faces)) +} + +/// Rotate's plane ([`crate::plane`]) as a mesh. +pub fn plane_mesh(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result { + plane_faces(path, rule, bounds, view, true).and_then(|(_, faces)| mesh(&faces)) +} + +/// Inflate's solids for several shapes of one object ([`crate::inflate_together`]) as meshes, one +/// result for each shape, in order. Each mesh is cut as the iterator reaches it. +pub fn inflate_meshes( + shapes: &[(&PathData, FillRule)], + bounds: Rect, + options: Inflate, + view: View, +) -> impl Iterator> + use<> { + inflate_faces(shapes, bounds, options, view, true).map(|r| r.and_then(|(_, faces)| mesh(&faces))) +} + +/// Revolve's surface ([`crate::revolve`]) as a mesh. As the effect draws it, a sweep of less than +/// 360° caps its ends with sectors and leaves its two cut sides open ([`Revolve::cap_ends`]). +pub fn revolve_mesh(path: &PathData, bounds: Rect, options: Revolve) -> Result { + let mut faces = vec![]; + crate::revolve_faces(path, bounds, options, Some(&mut faces))?; + mesh(&faces) +} + +/// The area-weighted normal of `contours` (Newell's method). +fn newell(contours: &[Vec]) -> V3 { + let mut n = V3::default(); + for c in contours { + for (a, b) in c.iter().zip(c.iter().cycle().skip(1)).take(c.len()) { + let x = a.cross(*b); + n = V3(n.0 + x.0, n.1 + x.1, n.2 + x.2); + } + } + n +} + +/// From the effects' view space (y down) to the mesh's (y up). +fn up(v: V3) -> [f64; 3] { + [v.0, -v.1, v.2] +} + +/// `faces` (view space) as one mesh, each cut into triangles turned to its normal, which is written +/// at unit length whatever length it is given (a face given no normal takes its corners' own). +fn mesh(faces: &[Face3]) -> Result { + let mut out = Mesh::default(); + let mut work = 0usize; + for (contours, normal) in faces { + let geometric = newell(contours); + let given = normal.unit(); + let n = if given.dot(given) > 0.5 { given } else { geometric.unit() }; + if n.dot(n) < 0.5 { + continue; + } + let [nx, ny, nz] = up(n); + for [a, b, c] in triangulate(contours, geometric, &mut work)? { + let (pa, pb, pc) = (up(a), up(b), up(c)); + let cross = V3(pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]).cross(V3(pc[0] - pa[0], pc[1] - pa[1], pc[2] - pa[2])); + if cross.dot(cross) < 1e-24 { + continue; + } + let (pb, pc) = if cross.dot(V3(nx, ny, nz)) < 0.0 { (pc, pb) } else { (pb, pc) }; + let base = u32::try_from(out.positions.len()).map_err(|_| "The 3D object is too complex to export".to_string())?; + out.positions.extend([pa, pb, pc]); + out.normals.extend([[nx, ny, nz]; 3]); + out.triangles.push([base, base + 1, base + 2]); + } + } + Ok(out) +} + +/// A corner of a face: its position on the plane the face is cut in, and in 3D. +#[derive(Clone, Copy)] +struct Corner { + x: f64, + y: f64, + v: V3, +} + +fn area(ring: &[Corner]) -> f64 { + ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).map(|(a, b)| a.x * b.y - b.x * a.y).sum::() / 2.0 +} + +fn inside(ring: &[Corner], x: f64, y: f64) -> bool { + let mut odd = false; + for (a, b) in ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()) { + if (a.y > y) != (b.y > y) && x < a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x) { + odd = !odd; + } + } + odd +} + +fn too_complex() -> String { + "The 3D object is too complex to export".to_string() +} + +fn crossing_hole() -> String { + "The 3D object has a hole that crosses its outline or another hole".to_string() +} + +/// Count `steps` of work against [`MAX_WORK`]. +fn spend(work: &mut usize, steps: usize) -> Result<(), String> { + *work = work.saturating_add(steps); + if *work > MAX_WORK { Err(too_complex()) } else { Ok(()) } +} + +/// The triangles covering one face: `contours` (outlines one way round, holes the other) on the +/// plane across `normal` (its Newell normal). +fn triangulate(contours: &[Vec], normal: V3, work: &mut usize) -> Result, String> { + // Cut the face on the coordinate plane it is least slanted to, turned so outlines run + // counterclockwise. + let (ax, ay, az) = (normal.0.abs(), normal.1.abs(), normal.2.abs()); + let flat = |v: V3| -> (f64, f64) { + if az >= ax && az >= ay { + if normal.2 >= 0.0 { (v.0, v.1) } else { (v.1, v.0) } + } else if ax >= ay { + if normal.0 >= 0.0 { (v.1, v.2) } else { (v.2, v.1) } + } else if normal.1 >= 0.0 { + (v.2, v.0) + } else { + (v.0, v.2) + } + }; + let mut outlines: Vec<(Vec, f64)> = vec![]; + let mut holes: Vec> = vec![]; + for c in contours { + let mut ring: Vec = c + .iter() + .map(|v| { + let (x, y) = flat(*v); + Corner { x, y, v: *v } + }) + .collect(); + ring.dedup_by(|a, b| (a.x - b.x).hypot(a.y - b.y) < 1e-9); + while ring.len() > 1 && ring.first().zip(ring.last()).is_some_and(|(a, b)| (a.x - b.x).hypot(a.y - b.y) < 1e-9) { + ring.pop(); + } + if ring.len() < 3 { + continue; + } + let a = area(&ring); + if a > 1e-12 { + outlines.push((ring, a)); + } else if a < -1e-12 { + holes.push(ring); + } + } + // Each hole goes to the smallest outline around it. + let mut held: Vec>> = vec![vec![]; outlines.len()]; + for hole in holes { + let corners: usize = outlines.iter().map(|(o, _)| o.len()).sum(); + spend(work, corners.saturating_mul(hole.len() + 1))?; + // A hole that crosses an outline can't be cut out of it; leaving it would fill it. + let edges = |r: &[Corner]| r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(p, q)| ((p.x, p.y), (q.x, q.y))).collect::>(); + let sides = edges(&hole); + if outlines.iter().any(|(o, _)| edges(o).iter().any(|(c, d)| sides.iter().any(|(a, b)| crosses(*a, *b, *c, *d)))) { + return Err(crossing_hole()); + } + let Some(first) = hole.first() else { continue }; + let owner = + outlines.iter().enumerate().filter(|(_, (o, _))| inside(o, first.x, first.y)).min_by(|a, b| a.1.1.total_cmp(&b.1.1)).map(|(i, _)| i); + if let Some(i) = owner + && let Some(h) = held.get_mut(i) + { + h.push(hole); + } + } + let mut out = vec![]; + for ((outline, _), mut holes) in outlines.into_iter().zip(held) { + // The rightmost hole first, as each bridge then reaches the outline past the others. + holes.sort_by(|a, b| { + let right = |h: &[Corner]| h.iter().map(|c| c.x).fold(f64::NEG_INFINITY, f64::max); + right(b).total_cmp(&right(a)) + }); + let mut ring = outline; + for k in 0..holes.len() { + let (hole, rest) = holes[k..].split_first().ok_or_else(too_complex)?; + ring = bridge(ring, hole, rest, work)?; + } + clip(&ring, &mut out, work)?; + } + Ok(out) +} + +/// Do segments `a`–`b` and `c`–`d` cross (touching at a shared end does not count)? +fn crosses(a: (f64, f64), b: (f64, f64), c: (f64, f64), d: (f64, f64)) -> bool { + let same = |p: (f64, f64), q: (f64, f64)| (p.0 - q.0).hypot(p.1 - q.1) < 1e-9; + if same(a, c) || same(a, d) || same(b, c) || same(b, d) { + return false; + } + let side = |p: (f64, f64), q: (f64, f64), r: (f64, f64)| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0); + let (d1, d2, d3, d4) = (side(c, d, a), side(c, d, b), side(a, b, c), side(a, b, d)); + ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0)) && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0)) +} + +/// Does point `p` lie on segment `a`–`b`, short of its ends? +fn touches(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> bool { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len = dx.hypot(dy); + if len < 1e-9 { + return false; + } + let along = ((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len; + let off = ((p.1 - a.1) * dx - (p.0 - a.0) * dy).abs() / len; + off < 1e-9 && along > 1e-9 && along < len - 1e-9 +} + +/// `ring` with `hole` joined to it: from the hole's rightmost corner to the nearest corner of the +/// ring that a straight bridge reaches inside the face: it crosses no edge of the ring, the hole or +/// the `others`, passes through none of their other corners, and its middle is inside the ring and +/// outside the holes. The bridge runs there and back, so the result is one ring. +fn bridge(ring: Vec, hole: &[Corner], others: &[Vec], work: &mut usize) -> Result, String> { + let Some(m) = (0..hole.len()).max_by(|a, b| hole[*a].x.total_cmp(&hole[*b].x)) else { return Ok(ring) }; + let at = (hole[m].x, hole[m].y); + let mut order: Vec = (0..ring.len()).collect(); + order.sort_by(|a, b| { + let d = |i: usize| (ring[i].x - at.0).hypot(ring[i].y - at.1); + d(*a).total_cmp(&d(*b)) + }); + let edges = |r: &[Corner]| r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(p, q)| ((p.x, p.y), (q.x, q.y))).collect::>(); + let mut walls = edges(&ring); + walls.extend(edges(hole)); + for o in others { + walls.extend(edges(o)); + } + let to = order.into_iter().find(|&i| { + let p = (ring[i].x, ring[i].y); + *work = work.saturating_add(2 * walls.len()); + if *work > MAX_WORK || walls.iter().any(|(c, d)| crosses(at, p, *c, *d) || touches(*c, at, p)) { + return false; + } + let mid = ((at.0 + p.0) / 2.0, (at.1 + p.1) / 2.0); + inside(&ring, mid.0, mid.1) && !inside(hole, mid.0, mid.1) && !others.iter().any(|o| inside(o, mid.0, mid.1)) + }); + if *work > MAX_WORK { + return Err(too_complex()); + } + // A hole no bridge reaches (one that crosses another hole) can't be cut out; leaving it would + // fill it. + let Some(i) = to else { return Err(crossing_hole()) }; + let mut out = Vec::with_capacity(ring.len() + hole.len() + 2); + out.extend_from_slice(&ring[..=i]); + out.extend(hole[m..].iter().chain(&hole[..=m]).copied()); + out.extend_from_slice(&ring[i..]); + Ok(out) +} + +/// Cut `ring` (counterclockwise, possibly joined to its holes by bridges) into triangles by ear +/// clipping. A corner in line with its neighbors goes without a triangle. If no ear is left (a +/// ring that crosses itself), the rest is cut as a fan. +fn clip(ring: &[Corner], out: &mut Vec<[V3; 3]>, work: &mut usize) -> Result<(), String> { + let n = ring.len(); + if n < 3 { + return Ok(()); + } + let mut next: Vec = (1..=n).map(|i| i % n).collect(); + let mut prev: Vec = (0..n).map(|i| (i + n - 1) % n).collect(); + let (mut left, mut i, mut tried) = (n, 0usize, 0usize); + let cross = |a: &Corner, b: &Corner, c: &Corner| (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); + let same = |a: &Corner, b: &Corner| (a.x - b.x).hypot(a.y - b.y) < 1e-12; + while left > 3 { + let (a, c) = (prev[i], next[i]); + let (pa, pb, pc) = (&ring[a], &ring[i], &ring[c]); + let turn = cross(pa, pb, pc); + let scale = (pb.x - pa.x).hypot(pb.y - pa.y) * (pc.x - pb.x).hypot(pc.y - pb.y); + let mut ear = false; + let drop = turn.abs() <= 1e-12 * scale.max(1e-300); + if !drop && turn > 0.0 { + spend(work, left)?; + ear = true; + let mut k = next[c]; + while k != a { + let p = &ring[k]; + if !same(p, pa) && !same(p, pb) && !same(p, pc) && cross(pa, pb, p) > 0.0 && cross(pb, pc, p) > 0.0 && cross(pc, pa, p) > 0.0 { + ear = false; + break; + } + k = next[k]; + } + } + if !ear && !drop && tried > left { + // No ear anywhere: cut what is left as a fan. + let start = i; + let mut k = next[start]; + while next[k] != start { + out.push([ring[start].v, ring[k].v, ring[next[k]].v]); + k = next[k]; + } + return Ok(()); + } + if ear || drop { + if ear { + out.push([pa.v, pb.v, pc.v]); + } + next[a] = c; + prev[c] = a; + left -= 1; + i = a; + tried = 0; + } else { + i = c; + tried += 1; + } + } + let (a, c) = (prev[i], next[i]); + out.push([ring[a].v, ring[i].v, ring[c].v]); + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/three-d/src/mesh/tests.rs b/crates/three-d/src/mesh/tests.rs new file mode 100644 index 000000000..3251cbb3e --- /dev/null +++ b/crates/three-d/src/mesh/tests.rs @@ -0,0 +1,318 @@ +use std::collections::HashMap; + +use vectorcraft_geom::{BezPath, FillRule, PathData, Rect}; + +use super::*; +use crate::{Bevel, Edge}; + +fn square(b: &mut BezPath, x: f64, y: f64, s: f64, clockwise: bool) { + b.move_to((x, y)); + if clockwise { + b.line_to((x, y + s)); + b.line_to((x + s, y + s)); + b.line_to((x + s, y)); + } else { + b.line_to((x + s, y)); + b.line_to((x + s, y + s)); + b.line_to((x, y + s)); + } + b.close_path(); +} + +fn shape(b: &BezPath) -> (PathData, Rect) { + let p = PathData::from_bezpath(b); + let r = p.bounds().unwrap(); + (p, r) +} + +/// Straight on, without perspective. +fn front() -> View { + View { rotation_x: 0.0, rotation_y: 0.0, rotation_z: 0.0, perspective: 0.0, ..View::default() } +} + +fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} + +fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} + +fn dot(a: [f64; 3], b: [f64; 3]) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn corners(m: &Mesh, t: [u32; 3]) -> [[f64; 3]; 3] { + t.map(|i| m.positions[i as usize]) +} + +/// The area of every triangle together. +fn area(m: &Mesh) -> f64 { + m.triangles + .iter() + .map(|t| { + let [a, b, c] = corners(m, *t); + dot(cross(sub(b, a), sub(c, a)), cross(sub(b, a), sub(c, a))).sqrt() / 2.0 + }) + .sum() +} + +/// Every edge of every triangle is met by an edge running the other way, as often: the surface is +/// closed and its triangles turn one way. +fn closed(m: &Mesh) -> bool { + let key = |p: [f64; 3]| p.map(|v| (v * 1e6).round() as i64); + let mut edges: HashMap<([i64; 3], [i64; 3]), i64> = HashMap::new(); + for t in &m.triangles { + let c = corners(m, *t).map(key); + for (a, b) in [(c[0], c[1]), (c[1], c[2]), (c[2], c[0])] { + *edges.entry((a, b)).or_default() += 1; + *edges.entry((b, a)).or_default() -= 1; + } + } + edges.values().all(|n| *n == 0) +} + +/// Every triangle of a solid convex about the origin turns its front away from the origin, and +/// agrees with its corners' normals. +fn outward(m: &Mesh) -> bool { + m.triangles.iter().all(|t| { + let [a, b, c] = corners(m, *t); + let n = cross(sub(b, a), sub(c, a)); + let centre = [(a[0] + b[0] + c[0]) / 3.0, (a[1] + b[1] + c[1]) / 3.0, (a[2] + b[2] + c[2]) / 3.0]; + dot(n, centre) > 0.0 && dot(n, m.normals[t[0] as usize]) > 0.0 + }) +} + +#[test] +fn an_extruded_square_is_a_closed_box_about_its_centre() { + let mut b = BezPath::new(); + square(&mut b, 200.0, 300.0, 100.0, false); + let (p, r) = shape(&b); + let m = extrude_mesh(&p, FillRule::NonZero, r, Extrude { depth: 50.0, ..Extrude::default() }, front()).unwrap(); + assert_eq!(m.triangles.len(), 12, "two triangles for each side of the box"); + assert_eq!(m.positions.len(), m.normals.len()); + assert_eq!(m.bounds(), Some(([-50.0, -50.0, -25.0], [50.0, 50.0, 25.0]))); + assert!((area(&m) - (2.0 * 100.0 * 100.0 + 4.0 * 100.0 * 50.0)).abs() < 1e-6, "{}", area(&m)); + assert!(closed(&m)); + assert!(outward(&m)); + assert!(m.normals.iter().all(|n| (dot(*n, *n) - 1.0).abs() < 1e-9)); +} + +#[test] +fn caps_with_holes_are_cut_around_them() { + // Two holes in one outline: each cap keeps the outline less both holes. + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + square(&mut b, 10.0, 10.0, 30.0, true); + square(&mut b, 60.0, 50.0, 30.0, true); + let (p, r) = shape(&b); + let m = extrude_mesh(&p, FillRule::NonZero, r, Extrude { depth: 20.0, ..Extrude::default() }, front()).unwrap(); + let caps = 2.0 * (100.0 * 100.0 - 2.0 * 30.0 * 30.0); + let walls = 4.0 * 100.0 * 20.0 + 2.0 * 4.0 * 30.0 * 20.0; + assert!((area(&m) - (caps + walls)).abs() < 1e-6, "{} != {}", area(&m), caps + walls); + assert!(closed(&m)); +} + +#[test] +fn a_bridge_from_a_hole_never_runs_through_the_hole() { + // The hole's rightmost corner (60, 50) is nearest the outline's spike tip (10, 50), and the + // straight line to it runs through the hole and out at its corner (20, 50). + let poly = |b: &mut BezPath, pts: &[(f64, f64)]| { + for (k, p) in pts.iter().enumerate() { + if k == 0 { b.move_to(*p) } else { b.line_to(*p) } + } + b.close_path(); + }; + let mut b = BezPath::new(); + poly(&mut b, &[(0.0, 0.0), (200.0, 0.0), (200.0, 100.0), (0.0, 100.0), (0.0, 55.0), (10.0, 50.0), (0.0, 45.0)]); + let hole = [(60.0, 50.0), (30.0, 40.0), (20.0, 50.0), (30.0, 60.0)]; + poly(&mut b, &hole); + let (p, r) = shape(&b); + let m = plane_mesh(&p, FillRule::EvenOdd, r, front()).unwrap(); + let face = 200.0 * 100.0 - 50.0 - 400.0; + assert!((area(&m) - face).abs() < 1e-6, "{} != {face}", area(&m)); + // No triangle's centre is in the hole (page coordinates: the mesh is y up about (100, 50)). + let in_hole = |x: f64, y: f64| { + let mut odd = false; + for (k, a) in hole.iter().enumerate() { + let c = hole[(k + 1) % hole.len()]; + if (a.1 > y) != (c.1 > y) && x < a.0 + (y - a.1) / (c.1 - a.1) * (c.0 - a.0) { + odd = !odd; + } + } + odd + }; + for t in &m.triangles { + let [a, b, c] = corners(&m, *t); + let (x, y) = ((a[0] + b[0] + c[0]) / 3.0 + 100.0, 50.0 - (a[1] + b[1] + c[1]) / 3.0); + assert!(!in_hole(x, y), "a triangle at ({x}, {y})"); + } +} + +#[test] +fn a_hole_that_crosses_the_outline_is_refused_rather_than_filled() { + // The hole starts inside the square, but its rightmost corner (15, 5) lies outside it, so no + // bridge from there reaches the square through the face. + let ring = |pts: &[(f64, f64)]| pts.iter().map(|(x, y)| V3(*x, *y, 0.0)).collect::>(); + let outline = ring(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]); + let hole = ring(&[(5.0, 4.0), (5.0, 6.0), (15.0, 5.0)]); + let mut work = 0; + assert!(super::triangulate(std::slice::from_ref(&outline), V3(0.0, 0.0, 1.0), &mut work).is_ok()); + let r = super::triangulate(&[outline.clone(), hole], V3(0.0, 0.0, 1.0), &mut work); + assert!(r.as_ref().is_err_and(|e| e.contains("hole")), "{:?}", r.map(|t| t.len())); + // The same hole starting outside the square belongs to no outline, and is refused too. + let hole = ring(&[(15.0, 5.0), (5.0, 4.0), (5.0, 6.0)]); + let r = super::triangulate(&[outline, hole], V3(0.0, 0.0, 1.0), &mut work); + assert!(r.as_ref().is_err_and(|e| e.contains("hole")), "{:?}", r.map(|t| t.len())); +} + +#[test] +fn a_bridge_through_another_corner_touches_it() { + assert!(super::touches((5.0, 0.0), (0.0, 0.0), (10.0, 0.0))); + assert!(!super::touches((0.0, 0.0), (0.0, 0.0), (10.0, 0.0)), "an end is not a touch"); + assert!(!super::touches((5.0, 1.0), (0.0, 0.0), (10.0, 0.0))); +} + +#[test] +fn a_beveled_extrusion_stays_closed() { + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + let (p, r) = shape(&b); + let bevel = Bevel { width: 6.0, height: 6.0, both_sides: true, ..Bevel::default() }; + let m = extrude_mesh(&p, FillRule::NonZero, r, Extrude { depth: 40.0, bevel: Some(bevel), ..Extrude::default() }, front()).unwrap(); + assert!(closed(&m)); + assert!(outward(&m)); +} + +#[test] +fn the_mesh_keeps_the_turn_of_the_view() { + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + let (p, r) = shape(&b); + // Turned 90° about the vertical axis, the extrusion's depth lies along x. + let view = View { rotation_y: 90.0, ..front() }; + let m = extrude_mesh(&p, FillRule::NonZero, r, Extrude { depth: 20.0, ..Extrude::default() }, view).unwrap(); + let (lo, hi) = m.bounds().unwrap(); + assert!((hi[0] - lo[0] - 20.0).abs() < 1e-9 && (hi[2] - lo[2] - 100.0).abs() < 1e-9, "{lo:?} {hi:?}"); +} + +#[test] +fn a_turned_plane_keeps_its_area_less_its_hole() { + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + square(&mut b, 25.0, 25.0, 50.0, true); + let (p, r) = shape(&b); + let view = View { rotation_x: 30.0, rotation_y: -40.0, ..front() }; + let m = plane_mesh(&p, FillRule::NonZero, r, view).unwrap(); + assert!((area(&m) - (100.0 * 100.0 - 50.0 * 50.0)).abs() < 1e-6, "{}", area(&m)); +} + +#[test] +fn y_points_up() { + // The art's top edge (y 0 on the page) is the mesh's highest. + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.line_to((100.0, 0.0)); + b.line_to((50.0, 100.0)); + b.close_path(); + let (p, r) = shape(&b); + let m = plane_mesh(&p, FillRule::NonZero, r, front()).unwrap(); + let top = m.positions.iter().filter(|q| (q[1] - 50.0).abs() < 1e-9).count(); + assert!(top >= 2, "{:?}", m.positions); + assert!(m.normals.iter().all(|n| n[2] > 0.99), "a plane facing the viewer has its normal toward +z"); +} + +#[test] +fn a_revolved_profile_is_a_closed_cylinder() { + // A vertical line 10 pt from the axis, swept round with its ends capped: a closed cylinder. + let mut b = BezPath::new(); + b.move_to((20.0, 0.0)); + b.line_to((20.0, 100.0)); + let p = PathData::from_bezpath(&b); + let r = p.bounds().unwrap(); + let o = Revolve { offset: 10.0, edge: Edge::Left, segments: 64, ..Revolve::default() }; + let m = revolve_mesh(&p, r, o).unwrap(); + assert!(closed(&m)); + let exact = 2.0 * std::f64::consts::PI * 10.0 * 100.0 + 2.0 * std::f64::consts::PI * 100.0; + assert!((area(&m) - exact).abs() / exact < 0.01, "{} vs {exact}", area(&m)); + assert!(outward(&m)); +} + +#[test] +fn a_partial_revolve_caps_its_ends_with_sectors() { + let mut b = BezPath::new(); + b.move_to((20.0, 0.0)); + b.line_to((20.0, 100.0)); + let p = PathData::from_bezpath(&b); + let r = p.bounds().unwrap(); + let o = Revolve { angle: 90.0, offset: 10.0, segments: 64, ..Revolve::default() }; + let m = revolve_mesh(&p, r, o).unwrap(); + let exact = 0.25 * (2.0 * std::f64::consts::PI * 10.0 * 100.0 + 2.0 * std::f64::consts::PI * 100.0); + assert!((area(&m) - exact).abs() / exact < 0.01, "{} vs {exact}", area(&m)); +} + +#[test] +fn a_revolved_surface_has_unit_normals_facing_out_whatever_its_length_and_direction() { + // A wall 100 pt tall and one 0.5 pt tall, each drawn up and down the page. + for (from, to) in [(0.0, 100.0), (100.0, 0.0), (0.0, 0.5), (0.5, 0.0)] { + let mut b = BezPath::new(); + b.move_to((20.0, from)); + b.line_to((20.0, to)); + let p = PathData::from_bezpath(&b); + let r = p.bounds().unwrap(); + let o = Revolve { offset: 10.0, edge: Edge::Left, segments: 64, ..Revolve::default() }; + let m = revolve_mesh(&p, r, o).unwrap(); + assert!(!m.is_empty(), "{from} to {to}: the revolve made no triangles"); + assert!(m.normals.iter().all(|n| (dot(*n, *n) - 1.0).abs() < 1e-9), "{from} to {to}: a normal is not unit length"); + assert!(closed(&m), "{from} to {to}"); + assert!(outward(&m), "{from} to {to}: a face turns inward"); + } +} + +#[test] +fn inflate_puffs_the_front_toward_the_viewer() { + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + let (p, r) = shape(&b); + let m = inflate_meshes(&[(&p, FillRule::NonZero)], r, Inflate::default(), front()).last().unwrap().unwrap(); + assert!(!m.is_empty()); + let (lo, hi) = m.bounds().unwrap(); + assert!(hi[2] > 0.0 && lo[2] < hi[2], "the front puffs toward the viewer: {lo:?} {hi:?}"); +} + +#[test] +fn inflate_builds_each_solid_as_it_is_reached() { + let (mut a, mut b) = (BezPath::new(), BezPath::new()); + square(&mut a, 0.0, 0.0, 100.0, false); + square(&mut b, 120.0, 0.0, 100.0, false); + let ((pa, _), (pb, _)) = (shape(&a), shape(&b)); + let built = || crate::solid::INFLATED.with(|n| n.get()); + let start = built(); + let mut meshes = + inflate_meshes(&[(&pa, FillRule::NonZero), (&pb, FillRule::NonZero)], Rect::new(0.0, 0.0, 220.0, 100.0), Inflate::default(), front()); + assert_eq!(built(), start, "no solid is built before the first mesh is asked for"); + assert!(meshes.next().is_some_and(|m| m.is_ok_and(|m| !m.is_empty()))); + assert_eq!(built(), start + 1, "the second solid waits until it is reached"); + assert!(meshes.next().is_some_and(|m| m.is_ok_and(|m| !m.is_empty()))); + assert_eq!(built(), start + 2); +} + +#[test] +fn an_empty_shape_makes_an_empty_mesh() { + let p = PathData::new(vec![]); + let m = plane_mesh(&p, FillRule::NonZero, Rect::new(0.0, 0.0, 10.0, 10.0), front()).unwrap(); + assert!(m.is_empty()); + assert_eq!(m.bounds(), None); +} + +#[test] +fn join_numbers_each_parts_corners_after_the_last() { + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 10.0, false); + let (p, r) = shape(&b); + let one = plane_mesh(&p, FillRule::NonZero, r, front()).unwrap(); + let both = Mesh::join([one.clone(), one.clone()]); + assert_eq!(both.triangles.len(), 2 * one.triangles.len()); + assert!(both.triangles.iter().all(|t| t.iter().all(|i| (*i as usize) < both.positions.len()))); + assert_eq!(both.triangles[one.triangles.len()][0], one.triangles[0][0] + one.positions.len() as u32); +} diff --git a/crates/three-d/src/shading.rs b/crates/three-d/src/shading.rs index b0772e7cf..d351e9749 100644 --- a/crates/three-d/src/shading.rs +++ b/crates/three-d/src/shading.rs @@ -1,7 +1,99 @@ -//! Gouraud lighting represented by an ordinary vector linear gradient on each triangle. +//! Gouraud lighting represented by an ordinary vector linear gradient on each triangle, and the +//! flat surface shading of the classic 3D effects. use vectorcraft_geom::{Point, Vec2}; +/// The surface of a classic 3D effect: how its faces are drawn and lit. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ClassicSurface { + /// Only the outline of every face, hidden ones included; the faces are transparent. + Wireframe, + /// Every face in the art's own paint, unlit. + NoShading, + /// Soft light: each face as bright as it faces the light. + Diffuse, + /// Diffuse light plus a glossy highlight where a face mirrors the light toward the viewer. + #[default] + Plastic, +} + +impl ClassicSurface { + pub const ALL: [ClassicSurface; 4] = [Self::Wireframe, Self::NoShading, Self::Diffuse, Self::Plastic]; + + pub fn id(self) -> &'static str { + match self { + Self::Wireframe => "wireframe", + Self::NoShading => "none", + Self::Diffuse => "diffuse", + Self::Plastic => "plastic", + } + } + + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|v| v.id() == s) + } +} + +/// The surface settings of a classic 3D effect. Shares are 0..1. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ClassicShading { + pub surface: ClassicSurface, + /// Light every face gets, whichever way it faces. + pub ambient: f64, + /// How bright the highlight is at its center (Plastic only). + pub highlight_intensity: f64, + /// How wide the highlight spreads: 1 broad, 0 a pinpoint (Plastic only). + pub highlight_size: f64, + /// How many levels the shading and the highlight are rounded to, 1..256. + pub blend_steps: u32, +} + +impl Default for ClassicShading { + fn default() -> Self { + Self { surface: ClassicSurface::Plastic, ambient: 0.5, highlight_intensity: 0.6, highlight_size: 0.9, blend_steps: 25 } + } +} + +impl ClassicShading { + /// The shine exponent of the highlight: 2 at the largest size, 256 at the smallest. + fn shininess(self) -> f64 { + 2f64.powf(1.0 + 7.0 * (1.0 - self.highlight_size.clamp(0.0, 1.0))) + } + + /// `x` (0..1) rounded to the nearest of the blend steps' levels. + fn stepped(self, x: f64) -> f64 { + let steps = f64::from(self.blend_steps.clamp(1, 256)); + ((x.clamp(0.0, 1.0) * steps).round() / steps).clamp(0.0, 1.0) + } + + /// The tone (0 in full shadow, 1 fully lit) and the highlight (0 none, 1 white) of a face with + /// view-space unit normal `normal`, seen from the front and lit by `lights`: each a unit + /// direction toward the light and its intensity (0..1). The lights add up. Wireframe and No + /// Shading are unlit: tone 1, no highlight. + pub fn tone(self, normal: [f64; 3], lights: &[([f64; 3], f64)]) -> (f64, f64) { + if matches!(self.surface, ClassicSurface::Wireframe | ClassicSurface::NoShading) { + return (1.0, 0.0); + } + let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + let (mut tone, mut spec) = (self.ambient.clamp(0.0, 1.0), 0.0); + for &(light, intensity) in lights { + let intensity = if intensity.is_finite() { intensity.clamp(0.0, 1.0) } else { 0.0 }; + let lit = dot(normal, light).max(0.0); + tone += intensity * lit; + // The halfway direction between the light and the eye in front of the page. + let half = [light[0], light[1], light[2] + 1.0]; + let length = dot(half, half).sqrt(); + if self.surface == ClassicSurface::Plastic && length > 1e-12 && lit > 0.0 { + spec += intensity * (dot(normal, half) / length).max(0.0).powf(self.shininess()) * self.highlight_intensity.clamp(0.0, 1.0); + } + } + if !tone.is_finite() || !spec.is_finite() { + return (1.0, 0.0); + } + (self.stepped(tone), self.stepped(spec)) + } +} + /// Document-space gradient placement and the brightness at its two ends. Clipping a /// face must keep this placement unchanged, so newly cut edges retain their lighting. #[derive(Clone, Copy, Debug, PartialEq)] diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 043e34766..4d6e78188 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -20,7 +20,11 @@ mod rings; #[cfg(test)] use build::{Camera, cuts, edge_distance, newell, section}; +pub(crate) use build::{Face3, extrude_faces, plane_faces}; pub use build::{extrude, plane}; +#[cfg(test)] +pub(crate) use inflate::INFLATED; +pub(crate) use inflate::inflate_faces; pub use inflate::{inflate, inflate_together}; #[cfg(test)] use membrane::{Edges, Membrane, cell_triangles}; @@ -201,11 +205,14 @@ pub struct Extrude { /// Closed ends (a solid) or open ones (a hollow tube). pub cap: bool, pub bevel: Option, + /// Keep the faces of a closed solid that are turned away from the viewer (Draw Hidden Faces, + /// and the classic Wireframe surface), drawn behind the faces turned toward it. + pub hidden: bool, } impl Default for Extrude { fn default() -> Self { - Self { depth: 50.0, twist: 0.0, taper: 1.0, cap: true, bevel: None } + Self { depth: 50.0, twist: 0.0, taper: 1.0, cap: true, bevel: None, hidden: false } } } @@ -239,6 +246,9 @@ pub struct Facet { pub depth: f64, pub brightness: f64, pub illumination: [f64; 3], + /// The face's unit normal in view space (x right, y down, z toward the viewer), turned to the + /// side the viewer sees. + pub normal: [f64; 3], /// Smooth lighting on an inflated triangle, before clipping individual RGB channels. pub rgb_gradient: Option, /// The contours in view space, before projection, for tests that measure depths. diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index c78f2a38b..9948c4796 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -1,4 +1,4 @@ -use super::rings::{MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, inset, left, rings, signed_area}; +use super::rings::{MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, inset, left, signed_area, solid_rings}; use super::{Bevel, Extrude, Facet, FillRule, MAX_FACES, PathData, Point, Rect, Revolve, V3, View, eye_distance, perspective_scale}; /// Turns 3D points into document points and shades faces for one view. @@ -41,13 +41,25 @@ impl Camera { } } +/// A face of a solid before projection: its contours in view space (holes running the other way) +/// and its outward normal. +pub(crate) type Face3 = (Vec>, V3); + +/// What a solid's builder makes: its facets, or with a mesh asked for, its faces before projection. +pub(crate) type Built = (Vec, Vec); + /// Facets being collected for one solid. pub(super) struct Builder<'a> { pub(super) camera: &'a Camera, /// A closed solid hides the faces turned away from the viewer; an open one shows their /// insides. pub(super) closed: bool, + /// A closed solid keeps the faces turned away from the viewer too, with their outward normals, + /// drawn behind the others. + pub(super) hidden: bool, pub(super) facets: Vec, + /// With `Some`, every face goes here as it is, none hidden or projected, for a mesh. + pub(super) mesh: Option>, } /// Where a face goes in the order the facets are drawn, back to front. @@ -73,6 +85,13 @@ impl Builder<'_> { if count < 3 { return Ok(()); } + if let Some(mesh) = &mut self.mesh { + mesh.push((contours, n.unit())); + if mesh.len() > MAX_FACES { + return Err("The 3D surface is too complex; simplify the shape".into()); + } + return Ok(()); + } let centre = contours.iter().flatten().fold(V3::default(), |s, v| V3(s.0 + v.0, s.1 + v.1, s.2 + v.2)); let centre = V3(centre.0 / count as f64, centre.1 / count as f64, centre.2 / count as f64); let n = n.unit(); @@ -83,7 +102,7 @@ impl Builder<'_> { // leave gaps. let (n, behind) = if facing > 0.0 { (n, false) - } else if self.closed && facing > -0.02 { + } else if self.closed && (facing > -0.02 || self.hidden) { (n, true) } else if self.closed { return Ok(()); @@ -111,6 +130,7 @@ impl Builder<'_> { depth, brightness: self.camera.brightness(n), illumination: crate::illumination(&self.camera.view, n, self.camera.light), + normal: [n.0, n.1, n.2], rgb_gradient: None, #[cfg(test)] view, @@ -182,9 +202,15 @@ impl Builder<'_> { Ok(()) } - pub(super) fn finish(mut self) -> Vec { + /// How many faces the builder holds: facets, or faces kept for a mesh. + pub(super) fn len(&self) -> usize { + self.mesh.as_ref().map_or(self.facets.len(), Vec::len) + } + + /// The facets back to front, and the faces kept for a mesh (none without [`Builder::mesh`]). + pub(super) fn finish(mut self) -> Built { self.facets.sort_by(|a, b| a.depth.total_cmp(&b.depth)); - self.facets + (self.facets, self.mesh.unwrap_or_default()) } } @@ -370,15 +396,20 @@ fn steps(o: &Extrude) -> Vec { /// Extrude & Bevel: the shape's outline extruded backwards, with optional twist, taper, bevels /// and caps. pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result, String> { + extrude_faces(path, rule, bounds, options, view, false).map(|(facets, _)| facets) +} + +/// [`extrude`], or with `mesh` the solid's faces before projection instead of its facets. +pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View, mesh: bool) -> Result { check_bounds(bounds)?; let o = options.sanitized(); // No depth: the flat shape, seen from either side. if o.depth <= 0.0 { - return plane(path, rule, bounds, view); + return plane_faces(path, rule, bounds, view, mesh); } - let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; + let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; let camera = Camera::new(view, bounds, o.depth); - let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] }; + let mut b = Builder { camera: &camera, closed: o.cap, hidden: o.hidden, facets: vec![], mesh: mesh.then(Vec::new) }; let steps = steps(&o); let z = |t: f64| o.depth / 2.0 - o.depth * t; let ring_at = @@ -428,10 +459,15 @@ pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, /// Rotate: the flat art turned in 3D about the centre of `bounds` (which may be larger than the /// shape's own, so that every part of one object turns about the same point). pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result, String> { + plane_faces(path, rule, bounds, view, false).map(|(facets, _)| facets) +} + +/// [`plane`], or with `mesh` its face before projection instead of its facet. +pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, mesh: bool) -> Result { check_bounds(bounds)?; - let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?; + let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?; let camera = Camera::new(view, bounds, 0.0); - let mut b = Builder { camera: &camera, closed: false, facets: vec![] }; + let mut b = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) }; let contours: Vec> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); // A plane is one flat face, seen from either side. Its normal is turned toward the viewer, so // it is drawn last, as a cap is, and planes drawn in one order keep the order they are given in. diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index d80fef185..51f9af911 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -1,6 +1,6 @@ -use super::build::{Builder, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward}; +use super::build::{Builder, Built, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward}; use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, filled, material}; -use super::rings::{MAX_SOLID_POINTS, rings, signed_area}; +use super::rings::{MAX_SOLID_POINTS, signed_area, solid_rings}; use super::{Facet, FillRule, Inflate, MAX_FACES, PathData, Point, Rect, V3, Vec2, View}; /// A shape to inflate: its rings and the membrane under them on the grid of the bounds. @@ -18,7 +18,7 @@ struct Puff { /// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area. fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result, String> { - let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; + let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; if rings.is_empty() { return Ok(None); } @@ -51,23 +51,42 @@ pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, /// on its own over the grid of `bounds`, and one eye sees them all, as far away as the highest /// puff needs with Perspective. One result for each shape, in order. pub fn inflate_together(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View) -> Vec, String>> { + inflate_faces(shapes, bounds, options, view, false).map(|r| r.map(|(facets, _)| facets)).collect() +} + +/// [`inflate_together`], or with `mesh` each solid's faces before projection instead of its facets. +/// Every shape's membrane is solved at once (the eye depends on the highest puff); each solid is +/// built as the iterator reaches it. +pub(crate) fn inflate_faces( + shapes: &[(&PathData, FillRule)], + bounds: Rect, + options: Inflate, + view: View, + mesh: bool, +) -> impl Iterator> + use<> { let o = options.sanitized(); let puffs: Vec, String>> = shapes.iter().map(|(path, rule)| check_bounds(bounds).and_then(|()| puff(path, *rule, bounds, &o))).collect(); let reach = puffs.iter().flatten().flatten().map(|p| p.height).fold(0.0, f64::max); - puffs - .into_iter() - .map(|p| match p { - Ok(Some(p)) => inflated(p, bounds, o, view, reach), - Ok(None) => Ok(vec![]), - Err(e) => Err(e), - }) - .collect() + puffs.into_iter().map(move |p| match p { + Ok(Some(p)) => inflated(p, bounds, o, view, reach, mesh), + Ok(None) => Ok((vec![], vec![])), + Err(e) => Err(e), + }) +} + +#[cfg(test)] +thread_local! { + /// How many inflated solids this thread has built, for the tests. + pub(crate) static INFLATED: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// The facets of one inflated shape, for a camera that allows for a puff `reach` high (or the -/// shape's own, if higher): `reach` is the highest puff of the shapes drawn together. -fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64) -> Result, String> { +/// shape's own, if higher): `reach` is the highest puff of the shapes drawn together. With `mesh`, +/// its faces before projection instead. +fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: bool) -> Result { + #[cfg(test)] + INFLATED.with(|n| n.set(n.get() + 1)); let Puff { rings, edges, cell, nx, ny, m, height } = p; let outline: Vec<(Rect, vectorcraft_geom::SubPath)> = rings .iter() @@ -106,7 +125,7 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64) -> Result let smooth = view.shade && view.has_colored_lights(); let camera = Camera::new(view, bounds, o.depth + 2.0 * height.max(reach)); // Without its back cap the object is a shell open behind, its inside showing. - let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] }; + let mut b = Builder { camera: &camera, closed: o.cap, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) }; let half = o.depth / 2.0; // The back: turned and scaled by the twist and taper, its puff too. let back_turn = Turn::new(bounds, o.twist, o.taper, 1.0); @@ -300,7 +319,7 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64) -> Result ring.iter().map(|p| camera.place(turn.apply(*p), half - o.depth * t)).collect() }; let mut pieces: Vec> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect(); - fit(&mut pieces, slices, MAX_FACES.saturating_sub(b.facets.len())); + fit(&mut pieces, slices, MAX_FACES.saturating_sub(b.len())); for (ring, pieces) in rings.iter().zip(&pieces) { for k in 0..slices { b.band(&at(ring, k), &at(ring, k + 1), pieces, |i| outward(ring, i, 0.0))?; diff --git a/crates/three-d/src/solid/rings.rs b/crates/three-d/src/solid/rings.rs index 44a8590ac..81e67cc03 100644 --- a/crates/three-d/src/solid/rings.rs +++ b/crates/three-d/src/solid/rings.rs @@ -19,6 +19,175 @@ fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 { if n.is_finite() { (n as u32).clamp(1, 32) } else { 32 } } +/// Contours thinner than this on average (twice their area over their perimeter) are dropped when +/// overlaps are resolved: the debris a boolean leaves where two curves coincide, far below any +/// art. Pathfinder's default precision would drop real hairlines too. +pub(super) const DEBRIS: f64 = 1e-6; + +/// The rings of a solid's profile, as [`rings`], with contours that meet merged first. Type often +/// draws one glyph as overlapping contours (a "k"'s arm across its stem), and [`rings`] tells a +/// contour's side from one point of it, so a contour that meets another, or itself, must be cut +/// where they meet. Most art has no such contours, and the boolean costs more than the rest of the +/// solid, so it runs only when [`edges_meet`] finds some. Its answer already runs holes against +/// their outlines, so it is only turned as a whole ([`wound`]), never judged a ring at a time. +/// +/// The `limit` is checked on the profile as drawn, before the boolean, and again on the merged +/// outline, which can have more points (bars crossing in a grid trace every hole between them): +/// past it the shape is too complex, since its contours as drawn cross and can't be built as they +/// are. A profile the boolean refuses is built from its contours as drawn, as it was before +/// contours were merged. An empty answer is an answer: contours that cancel (a square drawn twice +/// under even-odd), or that are all thinner than [`DEBRIS`], fill nothing, so nothing is built. +pub(super) fn solid_rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result>, String> { + let sampled = sample(path, size, limit)?; + let closed = closed_straight(path); + if !meets(&sampled, &closed) { + return orient(sampled, rule); + } + match vectorcraft_pathops::try_unite_all_with(&[(&closed, rule)], DEBRIS) { + Ok(merged) => sample(&merged, size, limit).map(wound), + Err(_) => orient(sampled, rule), + } +} + +/// Do the `sampled` rings of `closed` (the profile as the boolean takes it) meet, so that it must +/// go through the boolean? Within twice the boolean's tolerance ([`touch`]): pathops first snaps +/// anchors onto nearby heights (by less than the tolerance), then the sweep joins edges within it +/// along the sweep line, so straight edges a little more than it apart can come out as one: at +/// most sin θ + cos θ ≤ √2 times it, θ the edges' angle to the sweep line (about 1.4 at 45°, +/// measured; the same when pathops turns the sweep a quarter turn, which it does for some art of +/// 2,048 segments or more). Straight contours it might join are merged; those it leaves apart cost +/// a boolean that builds the same shape. Curves are judged on their sampled outline (see +/// [`sample`]), which can lie a little inside them (up to the sampling tolerance, or more for a +/// long curve held to 32 pieces), so curves that touch or overlap by less than that can be judged +/// apart and built as drawn. +pub(super) fn meets(sampled: &[Vec], closed: &PathData) -> bool { + edges_meet(sampled, 2.0 * touch(closed)) +} + +/// `path` as the boolean should take it: each open subpath closed by a straight line, as [`sample`] +/// and the renderer's fill close it (the boolean would close it along the end anchors' handles). +/// Borrowed when nothing needs changing: every subpath closed, a single anchor (which the boolean +/// skips), or open with no handle on the closing side of either end anchor (which the boolean +/// already closes straight). +pub(super) fn closed_straight(path: &PathData) -> std::borrow::Cow<'_, PathData> { + // The closing segment (last anchor to first) as the boolean will draw it once closed; the + // length test comes first, so a subpath of fewer than two anchors never reaches the index. + let straight = |sp: &vectorcraft_geom::SubPath| sp.closed || sp.anchors.len() < 2 || sp.segment_is_line(sp.anchors.len() - 1); + if path.subpaths.iter().all(straight) { + return std::borrow::Cow::Borrowed(path); + } + let mut path = path.clone(); + for sp in path.subpaths.iter_mut().filter(|sp| !sp.closed) { + if let Some(last) = sp.anchors.last_mut() { + last.h_out = last.p; + } + if let Some(first) = sp.anchors.first_mut() { + first.h_in = first.p; + } + sp.closed = true; + } + std::borrow::Cow::Owned(path) +} + +/// The boolean's rings turned so the filled side is on each one's left. Its holes already run +/// against their outlines, so only the direction of the whole is chosen: the one whose rings +/// enclose positive area in all. +pub(super) fn wound(mut rings: Vec>) -> Vec> { + rings.retain(|r| signed_area(r).abs() > 1e-9); + if rings.iter().map(|r| signed_area(r)).sum::() < 0.0 { + rings.iter_mut().for_each(|r| r.reverse()); + } + rings +} + +/// The boolean's sweep tolerance for `path` as it is given to it (closed by [`closed_straight`]): +/// 64 times the rounding of the largest coordinate it reads, every anchor and handle of each +/// subpath of two anchors or more, and at least 1e-6 (pathops' `eps_for`). For a path [`sample`] +/// accepts (every point finite). +pub(super) fn touch(path: &PathData) -> f64 { + let m = path + .subpaths + .iter() + .filter(|sp| sp.anchors.len() >= 2) + .flat_map(|sp| &sp.anchors) + .flat_map(|a| [a.p, a.h_in, a.h_out]) + .fold(0.0f64, |m, q| m.max(q.x.abs()).max(q.y.abs())); + (m * f64::EPSILON * 64.0).max(1e-6) +} + +/// Most pairs of edges [`edges_meet`] compares. Past it the answer is yes, and the boolean, with +/// its own budget, decides. +const MAX_EDGE_PAIRS: usize = 1 << 22; + +/// Do any two edges of `rings` meet, within `touch` (at that distance or closer): a contour crossing, touching or running +/// along another, or itself? Neighbouring edges of a ring share a corner and meet only where one +/// folds back along the other. Edges are swept in order of their left ends, so only pairs whose +/// bounds overlap across x are compared. +pub(super) fn edges_meet(rings: &[Vec], touch: f64) -> bool { + struct Edge { + ring: usize, + index: usize, + a: Point, + b: Point, + x0: f64, + x1: f64, + } + let mut edges: Vec = rings + .iter() + .enumerate() + .flat_map(|(ring, r)| { + r.iter().zip(r.iter().cycle().skip(1)).enumerate().map(move |(index, (a, b))| Edge { + ring, + index, + a: *a, + b: *b, + x0: a.x.min(b.x), + x1: a.x.max(b.x), + }) + }) + .collect(); + edges.sort_by(|e, f| e.x0.total_cmp(&f.x0)); + let len = |ring: usize| rings.get(ring).map_or(0, Vec::len); + let mut pairs = 0usize; + for (i, e) in edges.iter().enumerate() { + for f in edges.iter().skip(i + 1) { + if f.x0 > e.x1 + touch { + break; + } + pairs += 1; + if pairs > MAX_EDGE_PAIRS { + return true; + } + if e.a.y.min(e.b.y) > f.a.y.max(f.b.y) + touch || f.a.y.min(f.b.y) > e.a.y.max(e.b.y) + touch { + continue; + } + let n = len(e.ring); + let neighbours = e.ring == f.ring && ((e.index + 1) % n == f.index || (f.index + 1) % n == e.index); + if neighbours { + // They share a corner; they meet only if one runs back along the other. + let (shared, p, q) = if e.b == f.a { (e.b, e.a, f.b) } else { (e.a, e.b, f.a) }; + if super::build::segment_distance(q, shared, p) <= touch || super::build::segment_distance(p, shared, q) <= touch { + return true; + } + } else if segments_meet(e.a, e.b, f.a, f.b, touch) { + return true; + } + } + } + false +} + +/// Do segments `a`–`b` and `c`–`d` cross, or come within `touch` of each other? +fn segments_meet(a: Point, b: Point, c: Point, d: Point, touch: f64) -> bool { + let side = |p: Point, q: Point, r: Point| (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x); + let (d1, d2, d3, d4) = (side(c, d, a), side(c, d, b), side(a, b, c), side(a, b, d)); + if ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0)) && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0)) { + return true; + } + use super::build::segment_distance as dist; + dist(a, c, d).min(dist(b, c, d)).min(dist(c, a, b)).min(dist(d, a, b)) <= touch +} + /// The rings of a shape: closed polygons with the filled side on their left, (-dy, dx): outer /// outlines run one way and holes the other, so nonzero winding fills them as the art is. Curves /// are followed within a tolerance of 1/2000 of `size` (at least 0.05 pt), so small curves take @@ -28,11 +197,18 @@ fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 { /// is left out: a contour inside another that runs the same way doesn't cut a hole, as it would /// if nesting alone decided (#1724). pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result>, String> { + orient(sample(path, size, limit)?, rule) +} + +/// The closed polygons that follow `path`'s subpaths, as [`rings`] samples them, each running as +/// its subpath does. +pub(super) fn sample(path: &PathData, size: f64, limit: usize) -> Result>, String> { let tolerance = (size / 2000.0).max(0.05); let mut count = 0; let mut out = vec![]; - for sp in &path.subpaths { - let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; + // A subpath of one anchor draws nothing, closed or not (the renderer and the boolean skip it). + for sp in path.subpaths.iter().filter(|sp| sp.anchors.len() >= 2) { + let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len() - 1 }; if segments > limit { return Err("The shape is too complex for a 3D effect".into()); } @@ -58,10 +234,21 @@ pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> while ring.len() > 1 && ring.first().zip(ring.last()).is_some_and(|(a, b)| a.distance(*b) < 1e-9) { ring.pop(); } - if ring.len() >= 3 && signed_area(&ring).abs() > 1e-9 { + // Rings with no area are kept here: a contour that crosses itself into lobes that cancel + // (a bowtie) has none, and [`edges_meet`] must see it. [`orient`] leaves them out. A flat + // ring, every point on one line, fills nothing under either rule and is left out now, so + // that it doesn't send the profile through the boolean. + if ring.len() >= 3 && !flat(&ring) { out.push(ring); } } + Ok(out) +} + +/// `out` (sampled rings) turned so the filled side is on each one's left, and under the nonzero +/// `rule` without the rings that are no edge of the shape, as [`rings`] describes. +fn orient(mut out: Vec>, rule: FillRule) -> Result>, String> { + out.retain(|r| signed_area(r).abs() > 1e-9); // Which rings hold which: each ring against the others, its edges walked only where its // bounds hold the point, and within [`MAX_CONTAINMENT_EDGES`] edges and pairs in all (a pair // whose bounds miss the point costs a step too: many small rings are a square of pairs). @@ -118,6 +305,14 @@ pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> /// the others, so many rings of many points each would otherwise take billions of steps. const MAX_CONTAINMENT_EDGES: usize = 50_000_000; +/// Does every point of `ring` lie on one line (within 1e-9, as [`sample`] merges points)? +fn flat(ring: &[Point]) -> bool { + let Some(&a) = ring.first() else { return true }; + let Some(&b) = ring.iter().max_by(|p, q| a.distance(**p).total_cmp(&a.distance(**q))) else { return true }; + let len = a.distance(b); + len <= 1e-9 || ring.iter().all(|p| ((b - a).cross(*p - a) / len).abs() <= 1e-9) +} + pub(super) fn signed_area(r: &[Point]) -> f64 { r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(a, b)| a.x * b.y - b.x * a.y).sum::() / 2.0 } diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index 0f78aa712..46d178287 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -76,6 +76,28 @@ fn no_depth_is_the_flat_shape_and_no_cap_shows_the_inside() { assert!(hollow.iter().all(|f| f.contours.len() == 1 && f.contours[0].len() == 4)); } +#[test] +fn hidden_faces_draw_every_face_of_the_solid_behind_the_visible_ones() { + let (p, b) = shape(&square(0.0, 0.0, 100.0)); + let v = View { rotation_x: 20.0, rotation_y: 30.0, ..front() }; + let shown = extrude(&p, FillRule::EvenOdd, b, Extrude::default(), v).unwrap(); + let all = extrude(&p, FillRule::EvenOdd, b, Extrude { hidden: true, ..Extrude::default() }, v).unwrap(); + // A box: the three faces turned to the viewer, then the three turned away, drawn first. + assert_eq!(shown.len(), 3); + assert_eq!(all.len(), 6); + assert!(all.windows(2).all(|w| w[0].depth <= w[1].depth)); + // The faces turned away keep their outward normals, so a light behind the solid lights them. + assert!(all[..3].iter().all(|f| f.normal[2] < 0.0), "{:?}", all.iter().map(|f| f.normal).collect::>()); + for f in shown.iter().chain(&all) { + let [x, y, z] = f.normal; + assert!(close(x.hypot(y).hypot(z), 1.0, 1e-9), "{:?}", f.normal); + } + // The visible faces keep their normals and their place at the front. + for (front, kept) in shown.iter().rev().zip(all.iter().rev()) { + assert_eq!(front.normal, kept.normal); + } +} + #[test] fn taper_shrinks_the_back_and_twist_turns_it() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); @@ -232,6 +254,7 @@ fn malformed_and_degenerate_input_is_bounded() { taper: -3.0, cap: true, bevel: Some(Bevel { width: f64::NAN, height: 1e12, repeat: usize::MAX, space: 7.0, ..Bevel::default() }), + hidden: false, }; let f = extrude(&p, FillRule::EvenOdd, b, wild, View { rotation_x: f64::NAN, perspective: 900.0, ..front() }).unwrap(); assert!(f.iter().flat_map(|f| f.contours.iter().flatten()).all(|q| q.x.is_finite() && q.y.is_finite())); @@ -754,3 +777,407 @@ fn many_small_rings_far_apart_are_too_complex() { let e = rings(&p, FillRule::NonZero, r.width().max(r.height()), usize::MAX).unwrap_err(); assert!(e.contains("too complex"), "{e}"); } + +/// Type often draws a glyph as contours that cross (a "k"'s arm across its stem, its leg across +/// the arm). Each contour's side was told from its first point, so one starting inside another +/// was dropped or cut as a hole, and the parts left were walled where they overlapped. The +/// outline is the union the art fills, under either rule. +#[test] +fn crossing_contours_make_one_outline() { + let rect = |r: Rect| { + vectorcraft_geom::SubPath::polyline(&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)], true) + }; + // The stem, an arm starting inside it, and a leg starting inside the arm. + let k = PathData::new(vec![ + rect(Rect::new(0.0, 0.0, 20.0, 100.0)), + rect(Rect::new(10.0, 40.0, 60.0, 60.0)), + rect(Rect::new(50.0, 50.0, 70.0, 100.0)), + ]); + let b = k.bounds().unwrap(); + for rule in [FillRule::NonZero, FillRule::EvenOdd] { + let filled = vectorcraft_pathops::area(&k, rule); + assert!(close(area(&plane(&k, rule, b, front()).unwrap()), filled, 1e-6), "{rule:?}: Rotate"); + let f = extrude(&k, rule, b, Extrude::default(), front()).unwrap(); + assert_eq!(f.len(), 1, "{rule:?}: straight on, only the front cap shows"); + assert!(close(area(&f), filled, 1e-6), "{rule:?}: Extrude's cap"); + let puffed = inflate(&k, rule, b, Inflate::default(), front()).unwrap(); + assert_eq!(extent(&puffed), b, "{rule:?}: Inflate covers the whole k"); + assert!(close(area(&puffed), filled, filled * 1e-3), "{rule:?}: Inflate's front, {} against {filled}", area(&puffed)); + } + // The filled area is the union's: the stem, the arm past it and the leg past the arm. + assert!(close(vectorcraft_pathops::area(&k, FillRule::NonZero), 20.0 * 100.0 + 40.0 * 20.0 + 20.0 * 50.0 - 10.0 * 10.0, 1e-6)); +} + +/// Resolving overlaps keeps every contour that fills area, however narrow: a hairline beside a +/// large shape is art. What a boolean leaves where two curves coincide is still dropped. +#[test] +fn resolving_overlaps_keeps_narrow_contours() { + let rect = |r: Rect| { + vectorcraft_geom::SubPath::polyline(&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)], true) + }; + // A hairline beside a square, and two bars crossing elsewhere so the profile is resolved. + let hairline = Rect::new(120.0, 0.0, 120.005, 50.0); + let p = PathData::new(vec![ + rect(Rect::new(0.0, 0.0, 100.0, 100.0)), + rect(hairline), + rect(Rect::new(140.0, 40.0, 200.0, 60.0)), + rect(Rect::new(160.0, 20.0, 180.0, 80.0)), + ]); + let b = p.bounds().unwrap(); + let filled = |rule: FillRule| { + let bars = if rule == FillRule::NonZero { 60.0 * 20.0 + 20.0 * 40.0 } else { 60.0 * 20.0 + 20.0 * 40.0 - 20.0 * 20.0 }; + 100.0 * 100.0 + hairline.width() * hairline.height() + bars + }; + for rule in [FillRule::NonZero, FillRule::EvenOdd] { + assert!(close(area(&plane(&p, rule, b, front()).unwrap()), filled(rule), 1e-6), "{rule:?}: Rotate"); + assert!(close(area(&extrude(&p, rule, b, Extrude::default(), front()).unwrap()), filled(rule), 1e-6), "{rule:?}: Extrude's cap"); + } + // A circle drawn twice, exactly and a hair off, is one outline: no sliver between the copies. + let circle = |cx: f64, cy: f64, r: f64| { + let (mut b, k) = (BezPath::new(), 0.5523 * r); + b.move_to((cx + r, cy)); + b.curve_to((cx + r, cy + k), (cx + k, cy + r), (cx, cy + r)); + b.curve_to((cx - k, cy + r), (cx - r, cy + k), (cx - r, cy)); + b.curve_to((cx - r, cy - k), (cx - k, cy - r), (cx, cy - r)); + b.curve_to((cx + k, cy - r), (cx + r, cy - k), (cx + r, cy)); + b.close_path(); + b + }; + for dx in [0.0, 1e-7] { + let mut twice = circle(50.0, 50.0, 40.0); + twice.extend(circle(50.0 + dx, 50.0, 40.0).iter()); + let p = PathData::from_bezpath(&twice); + let f = plane(&p, FillRule::NonZero, p.bounds().unwrap(), front()).unwrap(); + assert_eq!(f.len(), 1, "{dx}"); + assert_eq!(f[0].contours.len(), 1, "{dx}"); + let once = PathData::from_bezpath(&circle(50.0, 50.0, 40.0)); + let one = area(&plane(&once, FillRule::NonZero, once.bounds().unwrap(), front()).unwrap()); + assert!(close(area(&f), one, one * 1e-6), "{dx}: the area of one circle, {} against {one}", area(&f)); + } +} + +/// Only a profile whose contours meet goes through the boolean: crossing, touching, running along +/// another or folding back on itself. Separate and nested contours are taken as they are. +#[test] +fn contours_meet_when_they_cross_touch_or_fold_back() { + let ring = |pts: &[(f64, f64)]| pts.iter().map(|&(x, y)| Point::new(x, y)).collect::>(); + let square = |x: f64, y: f64, s: f64| ring(&[(x, y), (x + s, y), (x + s, y + s), (x, y + s)]); + assert!(!rings::edges_meet(&[square(0.0, 0.0, 10.0), square(20.0, 0.0, 10.0)], 1e-6), "apart"); + assert!(!rings::edges_meet(&[square(0.0, 0.0, 10.0), square(3.0, 3.0, 4.0)], 1e-6), "nested, as an o's hole"); + assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(5.0, 5.0, 10.0)], 1e-6), "crossing"); + assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)], 1e-6), "sharing an edge"); + assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0, 3.0, 4.0)], 1e-6), "along part of an edge"); + assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0, 10.0, 4.0)], 1e-6), "at a corner"); + assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0 + 1e-7, 0.0, 10.0)], 1e-6), "within the boolean's snapping"); + assert!(rings::edges_meet(&[ring(&[(0.0, 0.0), (10.0, 10.0), (10.0, 0.0), (0.0, 10.0)])], 1e-6), "a bowtie crossing itself"); + assert!(rings::edges_meet(&[ring(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (10.0, 5.0), (0.0, 10.0)])], 1e-6), "folding back on itself"); + // Squares sharing an edge inflate as one puff, not two with a seam between them. + let shared = PathData::new( + [Rect::new(0.0, 0.0, 50.0, 50.0), Rect::new(50.0, 0.0, 100.0, 50.0)] + .iter() + .map(|r| { + vectorcraft_geom::SubPath::polyline( + &[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)], + true, + ) + }) + .collect(), + ); + let whole = PathData::from_bezpath(&{ + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.line_to((100.0, 0.0)); + b.line_to((100.0, 50.0)); + b.line_to((0.0, 50.0)); + b.close_path(); + b + }); + let b = whole.bounds().unwrap(); + let puff = |p: &PathData| inflate(p, FillRule::NonZero, b, Inflate::default(), front()).unwrap().len(); + assert_eq!(puff(&shared), puff(&whole)); +} + +/// Flat contours fill nothing and build nothing, crossing or not; an invalid coordinate is refused, +/// as it was; and a profile the boolean refuses is built from its contours as drawn. +#[test] +fn flat_invalid_and_refused_profiles() { + let flat = + PathData::new(vec![vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 10.0), Point::new(20.0, 20.0)], true)]); + let crossing_flat = PathData::new(vec![ + vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 10.0), Point::new(20.0, 20.0)], true), + vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 20.0), Point::new(10.0, 10.0), Point::new(20.0, 0.0)], true), + ]); + let b = Rect::new(0.0, 0.0, 20.0, 20.0); + for p in [&flat, &crossing_flat] { + assert_eq!(area(&plane(p, FillRule::NonZero, b, front()).unwrap()), 0.0); + assert_eq!(area(&extrude(p, FillRule::NonZero, b, Extrude::default(), front()).unwrap()), 0.0); + } + let mut bad = crossing_flat.clone(); + bad.subpaths[0].anchors[1].p = Point::new(f64::NAN, 10.0); + assert_eq!(plane(&bad, FillRule::NonZero, b, front()).unwrap_err(), "The shape contains invalid coordinates"); + // A star of 3,001 points, each joined to the one 1,500 on: every edge spans the middle, so + // past pathops' budget of segment pairs the boolean refuses it (`TooComplex`) without sweeping, + // and the profile is turned as drawn. + let n = 3001; + let star: Vec = (0..n) + .map(|i| { + let a = std::f64::consts::TAU * ((i * 1500) % n) as f64 / n as f64; + Point::new(100.0 + 100.0 * a.cos(), 100.0 + 100.0 * a.sin()) + }) + .collect(); + let star = PathData::new(vec![vectorcraft_geom::SubPath::polyline(&star, true)]); + assert_eq!( + vectorcraft_pathops::try_unite_all_with(&[(&star, FillRule::NonZero)], rings::DEBRIS), + Err(vectorcraft_pathops::PathOpsError::TooComplex) + ); + let b = star.bounds().unwrap(); + let drawn: f64 = + rings::rings(&star, FillRule::NonZero, b.width().max(b.height()), usize::MAX).unwrap().iter().map(|r| rings::signed_area(r)).sum(); + assert!(drawn > 0.0); + assert!(close(area(&plane(&star, FillRule::NonZero, b, front()).unwrap()), drawn, 1e-6), "built from its contours as drawn"); +} + +/// A contour that crosses itself into lobes that cancel has no area of its own, yet fills both +/// lobes; contours that cancel each other fill nothing. Both go through the boolean. +#[test] +fn self_crossing_and_cancelling_profiles_are_resolved() { + let poly = |pts: &[(f64, f64)]| vectorcraft_geom::SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::>(), true); + let bowtie = PathData::new(vec![poly(&[(0.0, 0.0), (10.0, 10.0), (10.0, 0.0), (0.0, 10.0)])]); + let b = bowtie.bounds().unwrap(); + for rule in [FillRule::NonZero, FillRule::EvenOdd] { + assert!(close(area(&plane(&bowtie, rule, b, front()).unwrap()), 50.0, 1e-6), "{rule:?}: Rotate"); + assert!(close(area(&extrude(&bowtie, rule, b, Extrude::default(), front()).unwrap()), 50.0, 1e-6), "{rule:?}: Extrude's cap"); + assert!(inflate(&bowtie, rule, b, Inflate::default(), front()).is_ok_and(|f| close(area(&f), 50.0, 1e-6)), "{rule:?}: Inflate"); + } + let square = poly(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]); + let twice = PathData::new(vec![square.clone(), square]); + let b = twice.bounds().unwrap(); + assert_eq!(area(&plane(&twice, FillRule::EvenOdd, b, front()).unwrap()), 0.0); + assert_eq!(area(&extrude(&twice, FillRule::EvenOdd, b, Extrude::default(), front()).unwrap()), 0.0); + assert!(inflate(&twice, FillRule::EvenOdd, b, Inflate::default(), front()).is_ok_and(|f| area(&f) == 0.0)); +} + +/// The boolean's answer often starts a contour where it touches another. Its rings are turned as +/// a whole, never judged from one point each, so touching parts are kept and holes stay open. +#[test] +fn touching_contours_keep_their_parts_and_holes() { + let poly = |pts: &[(f64, f64)]| vectorcraft_geom::SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::>(), true); + // A triangle whose tip rests on a square's edge. + let tip = PathData::new(vec![poly(&[(0.0, 10.0), (10.0, 10.0), (10.0, 20.0), (0.0, 20.0)]), poly(&[(0.0, 0.0), (10.0, 0.0), (5.0, 10.0)])]); + let b = tip.bounds().unwrap(); + for rule in [FillRule::NonZero, FillRule::EvenOdd] { + assert!(close(area(&plane(&tip, rule, b, front()).unwrap()), 150.0, 1e-6), "{rule:?}: Rotate"); + assert!(close(area(&extrude(&tip, rule, b, Extrude::default(), front()).unwrap()), 150.0, 1e-6), "{rule:?}: Extrude's cap"); + } + // An even-odd triangular hole touching its square's edge. + let hole = + PathData::new(vec![poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]), poly(&[(0.0, 50.0), (60.0, 0.0 + 20.0), (60.0, 80.0)])]); + let b = hole.bounds().unwrap(); + assert!(close(area(&plane(&hole, FillRule::EvenOdd, b, front()).unwrap()), 10000.0 - 1800.0, 1e-6)); +} + +/// An open subpath is closed by a straight line, whichever way the profile goes: the same as the +/// renderer's fill, whether or not other contours meet. +#[test] +fn an_open_subpath_closes_straight_whether_or_not_contours_meet() { + let open = vectorcraft_geom::SubPath { + anchors: vec![ + vectorcraft_geom::Anchor { + p: Point::new(0.0, 0.0), + h_in: Point::new(0.0, -50.0), + h_out: Point::new(0.0, 0.0), + kind: vectorcraft_geom::AnchorKind::Corner, + }, + vectorcraft_geom::Anchor::corner(Point::new(50.0, 100.0)), + vectorcraft_geom::Anchor { + p: Point::new(100.0, 0.0), + h_in: Point::new(100.0, 0.0), + h_out: Point::new(100.0, -50.0), + kind: vectorcraft_geom::AnchorKind::Corner, + }, + ], + closed: false, + }; + let rect = |x0: f64, y0: f64, x1: f64, y1: f64| { + vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true) + }; + let alone = PathData::new(vec![open.clone()]); + let with_bars = PathData::new(vec![open, rect(200.0, 40.0, 260.0, 60.0), rect(220.0, 20.0, 240.0, 80.0)]); + let bars = 60.0 * 20.0 + 20.0 * 40.0; + assert!(close(area(&plane(&alone, FillRule::NonZero, alone.bounds().unwrap(), front()).unwrap()), 5000.0, 1e-6)); + assert!(close(area(&plane(&with_bars, FillRule::NonZero, with_bars.bounds().unwrap(), front()).unwrap()), 5000.0 + bars, 1e-6)); +} + +/// A flat contour beside separate ones fills nothing and doesn't send the profile through the +/// boolean; contours far from the origin meet within the boolean's wider snapping; and a merged +/// outline past the point limit is too complex. +#[test] +fn flat_far_and_too_large_merges() { + let rect = |x0: f64, y0: f64, x1: f64, y1: f64| { + vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true) + }; + let squares: Vec<_> = (0..12).map(|i| rect(i as f64 * 20.0, 0.0, i as f64 * 20.0 + 10.0, 10.0)).collect(); + let line = vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 300.0), Point::new(50.0, 300.0), Point::new(100.0, 300.0)], false); + let with_line = PathData::new(squares.iter().cloned().chain([line]).collect()); + assert!(!rings::meets(&rings::sample(&with_line, 300.0, usize::MAX).unwrap(), &rings::closed_straight(&with_line))); + assert!(close(area(&plane(&with_line, FillRule::NonZero, with_line.bounds().unwrap(), front()).unwrap()), 1200.0, 1e-6)); + // Far from the origin the boolean snaps wider than 1e-6 (1.4e-6 at 9.9e7), and so does the + // test for meeting: squares 1.35e-6 apart there are one outline, one cap of both areas. + let far = PathData::new(vec![rect(9.9e7, 0.0, 9.9e7 + 10.0, 10.0), rect(9.9e7 + 10.0 + 1.35e-6, 0.0, 9.9e7 + 20.0, 10.0)]); + let cap = plane(&far, FillRule::NonZero, far.bounds().unwrap(), front()).unwrap(); + assert_eq!(cap.iter().map(|f| f.contours.len()).sum::(), 1, "joined into one outline"); + assert!(close(area(&cap), 200.0, 1e-3), "joined, not lost"); + // Handles count too: a lobe whose handles reach 1.3e8 widens the boolean's snapping to 1.85e-6. + let lobe = vectorcraft_geom::SubPath { + anchors: vec![ + vectorcraft_geom::Anchor { + p: Point::new(0.0, 1000.0), + h_in: Point::new(0.0, 1000.0), + h_out: Point::new(1.3e8, 1000.0), + kind: vectorcraft_geom::AnchorKind::Corner, + }, + vectorcraft_geom::Anchor { + p: Point::new(0.0, 1100.0), + h_in: Point::new(1.3e8, 1100.0), + h_out: Point::new(0.0, 1100.0), + kind: vectorcraft_geom::AnchorKind::Corner, + }, + ], + closed: true, + }; + let wide = PathData::new(vec![rect(9.9e7, 0.0, 9.9e7 + 10.0, 10.0), rect(9.9e7 + 10.0 + 1.6e-6, 0.0, 9.9e7 + 20.0, 10.0), lobe]); + let b = wide.bounds().unwrap(); + let joined = plane(&wide, FillRule::NonZero, b, front()).unwrap(); + assert_eq!(joined.iter().map(|f| f.contours.len()).sum::(), 2, "the squares joined, the lobe apart"); + // 24 bars each way: 192 points as drawn, some 2,400 once every hole between them is traced: + // past Extrude's and Inflate's 2,048 the shape is too complex (its contours as drawn cross, so + // they can't be built as they are), and Rotate's 65,536 takes it. + let mut bars = vec![]; + for i in 0..24 { + let x = i as f64 * 10.0; + bars.push(rect(x, 0.0, x + 4.0, 240.0)); + bars.push(rect(0.0, x, 240.0, x + 4.0)); + } + let grid = PathData::new(bars); + let b = grid.bounds().unwrap(); + for rule in [FillRule::NonZero, FillRule::EvenOdd] { + assert_eq!(extrude(&grid, rule, b, Extrude::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect", "{rule:?}"); + assert_eq!(inflate(&grid, rule, b, Inflate::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect", "{rule:?}"); + assert!(close(area(&plane(&grid, rule, b, front()).unwrap()), vectorcraft_pathops::area(&grid, rule), 1e-6), "{rule:?}: Rotate"); + } +} + +/// A profile goes through the boolean when its contours come within twice the boolean's tolerance +/// of the path it is given: pathops snaps anchors onto nearby heights before sweeping, then the +/// sweep joins edges within its tolerance, so contours a little more than it apart can come out +/// as one. +#[test] +fn meets_within_twice_the_boolean_tolerance() { + let rect = |x0: f64, y0: f64, x1: f64, y1: f64| { + vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true) + }; + let meets = |p: &PathData| rings::meets(&rings::sample(p, 300.0, usize::MAX).unwrap(), &rings::closed_straight(p)); + // Two triangles whose facing 45° edges are 1.4e-6 apart, the second's corners 0.99e-6 lower on + // the page (greater y) than the first's: snapped onto the first's heights, they are joined. + let d = 0.99e-6; + let snapped = PathData::new(vec![ + vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true), + vectorcraft_geom::SubPath::polyline(&[Point::new(10.0 + d, d), Point::new(10.0 + d, 10.0 + d), Point::new(d, 10.0 + d)], true), + ]); + assert!(meets(&snapped)); + let cap = plane(&snapped, FillRule::NonZero, snapped.bounds().unwrap(), front()).unwrap(); + assert_eq!(cap.iter().map(|f| f.contours.len()).sum::(), 1, "joined into one outline"); + assert!(close(area(&cap), 100.0, 1e-3), "joined, not lost"); + // At the same heights the boolean leaves the triangles apart, and the shape it builds is the + // one drawn: both triangles, two outlines. + let tri = |g: f64| { + PathData::new(vec![ + vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true), + vectorcraft_geom::SubPath::polyline(&[Point::new(10.0 + g, 0.0), Point::new(10.0 + g, 10.0), Point::new(g, 10.0)], true), + ]) + }; + let apart = tri(1.4e-6 * std::f64::consts::SQRT_2); + assert!(meets(&apart)); + assert_eq!(vectorcraft_pathops::try_unite_all_with(&[(&apart, FillRule::NonZero)], rings::DEBRIS).map(|p| p.subpaths.len()), Ok(2)); + let cap = plane(&apart, FillRule::NonZero, apart.bounds().unwrap(), front()).unwrap(); + assert_eq!(cap.iter().map(|f| f.contours.len()).sum::(), 2); + assert!(close(area(&cap), 100.0, 1e-3), "both kept"); + // Twice the tolerance, not less: 45° edges 1.9e-6 apart still meet. + assert!(meets(&tri(1.9e-6 * std::f64::consts::SQRT_2))); + // And pathops joins nothing past it: the worst case, 2.05e-6 apart as `meets` measures them, + // with the second triangle's corners just under the tolerance lower so that the snap brings it + // closest, stays two outlines. + let lift = 0.999e-6; + let g = 2.05e-6 * std::f64::consts::SQRT_2 - lift; + let past = PathData::new(vec![ + vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true), + vectorcraft_geom::SubPath::polyline(&[Point::new(10.0 + g, lift), Point::new(10.0 + g, 10.0 + lift), Point::new(g, 10.0 + lift)], true), + ]); + assert!(!meets(&past)); + assert_eq!(vectorcraft_pathops::try_unite_all_with(&[(&past, FillRule::NonZero)], rings::DEBRIS).map(|p| p.subpaths.len()), Ok(2)); + // Near the origin, contours a few millionths apart stay apart and skip the boolean. + assert!(!meets(&PathData::new(vec![rect(0.0, 0.0, 10.0, 10.0), rect(10.0 + 2.2e-6, 0.0, 20.0, 10.0)]))); + // What the boolean is given decides the tolerance: an open subpath's end handles (closed + // straight) and a one-anchor subpath (skipped) don't widen it. + let ends = vectorcraft_geom::SubPath { + anchors: vec![ + vectorcraft_geom::Anchor { + p: Point::new(0.0, 0.0), + h_in: Point::new(1e9, 0.0), + h_out: Point::new(0.0, 0.0), + kind: vectorcraft_geom::AnchorKind::Corner, + }, + vectorcraft_geom::Anchor { + p: Point::new(10.0, 0.0), + h_in: Point::new(10.0, 0.0), + h_out: Point::new(1e9, 0.0), + kind: vectorcraft_geom::AnchorKind::Corner, + }, + ], + closed: false, + }; + let speck = vectorcraft_geom::SubPath { anchors: vec![vectorcraft_geom::Anchor::corner(Point::new(1e9, 1e9))], closed: true }; + let quiet = PathData::new(vec![ends, speck]); + assert_eq!(rings::touch(&rings::closed_straight(&quiet)), 1e-6); + // A closing-side handle at either end alone is enough to close the subpath straight. + for kept in [0, 1] { + let mut one = quiet.subpaths[0].clone(); + one.anchors[0].h_in = if kept == 0 { Point::new(1e9, 0.0) } else { one.anchors[0].p }; + one.anchors[1].h_out = if kept == 1 { Point::new(1e9, 0.0) } else { one.anchors[1].p }; + assert_eq!(rings::touch(&rings::closed_straight(&PathData::new(vec![one]))), 1e-6, "handle kept at anchor {kept}"); + } + // Beside two squares 3e-6 apart near the origin (farther apart than twice 1e-6), they don't + // send the profile through the boolean; read unclosed, the end handles would. + let mut quiet_squares = quiet.clone(); + quiet_squares.subpaths.extend([rect(0.0, 100.0, 10.0, 110.0), rect(10.0 + 3e-6, 100.0, 20.0, 110.0)]); + assert!(!meets(&quiet_squares)); +} + +/// The boolean's rings are turned as a whole so the filled side is on their left: holes stay +/// against their outlines, and a profile wound the other way round is turned over. +#[test] +fn the_merged_rings_are_turned_as_a_whole() { + let outline = vec![Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)]; + let hole = vec![Point::new(2.0, 2.0), Point::new(2.0, 8.0), Point::new(8.0, 8.0), Point::new(8.0, 2.0)]; + let turned = rings::wound(vec![outline.iter().rev().copied().collect(), hole.iter().rev().copied().collect()]); + assert!(rings::signed_area(&turned[0]) > 0.0 && rings::signed_area(&turned[1]) < 0.0); + let kept = rings::wound(vec![outline.clone(), hole.clone()]); + assert_eq!(kept, vec![outline, hole]); +} + +/// A subpath of one anchor draws nothing, so it builds nothing, beside other art or not. +#[test] +fn a_one_anchor_subpath_builds_nothing() { + let loop_ = vectorcraft_geom::SubPath { + anchors: vec![vectorcraft_geom::Anchor { + p: Point::new(0.0, 0.0), + h_in: Point::new(-20.0, 30.0), + h_out: Point::new(20.0, 30.0), + kind: vectorcraft_geom::AnchorKind::Corner, + }], + closed: true, + }; + let p = PathData::new(vec![loop_]); + let b = Rect::new(-20.0, 0.0, 20.0, 30.0); + assert_eq!(area(&plane(&p, FillRule::NonZero, b, front()).unwrap()), 0.0); +} diff --git a/crates/three-d/src/tests.rs b/crates/three-d/src/tests.rs index a164c08fe..e2b6985fb 100644 --- a/crates/three-d/src/tests.rs +++ b/crates/three-d/src/tests.rs @@ -1,6 +1,7 @@ use super::*; mod caps; +mod classic; mod lighting; mod multiple_lights; mod smooth; diff --git a/crates/three-d/src/tests/classic.rs b/crates/three-d/src/tests/classic.rs new file mode 100644 index 000000000..1d807b4a3 --- /dev/null +++ b/crates/three-d/src/tests/classic.rs @@ -0,0 +1,96 @@ +use crate::*; + +const TOWARD: [f64; 3] = [0.0, 0.0, 1.0]; + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +fn one(light: [f64; 3], intensity: f64) -> [([f64; 3], f64); 1] { + [(light, intensity)] +} + +#[test] +fn wireframe_and_no_shading_leave_every_face_unlit() { + for surface in [ClassicSurface::Wireframe, ClassicSurface::NoShading] { + let s = ClassicShading { surface, ..ClassicShading::default() }; + for normal in [TOWARD, [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]] { + assert_eq!(s.tone(normal, &one(TOWARD, 1.0)), (1.0, 0.0), "{surface:?}"); + } + } +} + +#[test] +fn diffuse_adds_the_light_to_the_ambient_and_has_no_highlight() { + let s = ClassicShading { surface: ClassicSurface::Diffuse, ambient: 0.3, blend_steps: 256, ..ClassicShading::default() }; + let (lit, spec) = s.tone(TOWARD, &one(TOWARD, 0.4)); + assert!((lit - 0.7).abs() < 1.0 / 256.0, "{lit}"); + assert_eq!(spec, 0.0); + // A face turned away from the light gets the ambient light only. + let (away, _) = s.tone(TOWARD, &one([1.0, 0.0, 0.0], 0.4)); + assert!((away - 0.3).abs() < 1.0 / 256.0, "{away}"); + // A face at 60° to the light gets half of it. + let (half, _) = s.tone(TOWARD, &one([3f64.sqrt() / 2.0, 0.0, 0.5], 0.4)); + assert!((half - 0.5).abs() < 1.0 / 256.0, "{half}"); + // The tone stays within full shadow and full light. + assert_eq!(ClassicShading { ambient: 1.0, ..s }.tone(TOWARD, &one(TOWARD, 1.0)).0, 1.0); + // Without lights a face gets the ambient light only. + assert!((s.tone(TOWARD, &[]).0 - 0.3).abs() < 1.0 / 256.0); +} + +#[test] +fn lights_add_up() { + let s = ClassicShading { surface: ClassicSurface::Diffuse, ambient: 0.0, blend_steps: 256, ..ClassicShading::default() }; + let left = [-0.6, 0.0, 0.8]; + let right = [0.6, 0.0, 0.8]; + let both = s.tone(TOWARD, &[(left, 0.3), (right, 0.3)]).0; + let single = s.tone(TOWARD, &one(left, 0.3)).0; + assert!((both - 2.0 * single).abs() < 2.0 / 256.0, "{both} {single}"); + // A light behind the object lights the faces turned away from the viewer only. + assert_eq!(s.tone(TOWARD, &one([0.0, 0.0, -1.0], 1.0)).0, 0.0); + assert!(s.tone([0.0, 0.0, -1.0], &one([0.0, 0.0, -1.0], 1.0)).0 > 0.99); +} + +#[test] +fn plastic_highlight_peaks_where_a_face_mirrors_the_light_to_the_eye() { + let s = ClassicShading { highlight_intensity: 0.8, blend_steps: 256, ..ClassicShading::default() }; + let light = one([0.6, 0.0, 0.8], 1.0); + // The face halfway between the light and the eye mirrors the light straight to the viewer. + let length = 0.6f64.hypot(1.8); + let half = [0.6 / length, 0.0, 1.8 / length]; + let (_, peak) = s.tone(half, &light); + assert!((peak - 0.8).abs() < 1.0 / 256.0, "{peak}"); + let (_, aside) = s.tone([-0.6, 0.0, 0.8], &light); + assert!(aside < peak / 4.0, "{aside}"); + // A smaller highlight falls off faster. + let tilted = [0.3, 0.0, 0.954]; + let broad = s.tone(tilted, &light).1; + let narrow = ClassicShading { highlight_size: 0.1, ..s }.tone(tilted, &light).1; + assert!(narrow < broad, "{narrow} {broad}"); + // No highlight on a face the light does not reach, without Highlight Intensity, or on Diffuse. + assert_eq!(s.tone(TOWARD, &one([0.0, 0.0, -1.0], 1.0)).1, 0.0); + assert_eq!(ClassicShading { highlight_intensity: 0.0, ..s }.tone(half, &light).1, 0.0); + assert_eq!(ClassicShading { surface: ClassicSurface::Diffuse, ..s }.tone(half, &light).1, 0.0); +} + +#[test] +fn blend_steps_round_the_shading_to_that_many_levels() { + let s = ClassicShading { surface: ClassicSurface::Diffuse, ambient: 0.0, blend_steps: 2, ..ClassicShading::default() }; + let tones: Vec = (0..=20).map(|k| f64::from(k) / 20.0).map(|c| s.tone(TOWARD, &one([(1.0 - c * c).sqrt(), 0.0, c], 0.5)).0).collect(); + assert!(tones.iter().all(|t| close(*t, 0.0) || close(*t, 0.5)), "{tones:?}"); + assert!(tones.iter().any(|t| close(*t, 0.0)) && tones.iter().any(|t| close(*t, 0.5)), "{tones:?}"); + // Out-of-range step counts and intensities are kept within their ranges. + for steps in [0, 100_000] { + let t = ClassicShading { blend_steps: steps, ..s }.tone(TOWARD, &one(TOWARD, f64::NAN)).0; + assert!((0.0..=1.0).contains(&t)); + } +} + +#[test] +fn surfaces_round_trip_through_their_ids() { + for surface in ClassicSurface::ALL { + assert_eq!(ClassicSurface::parse(surface.id()), Some(surface)); + } + assert_eq!(ClassicSurface::parse("glossy"), None); + assert_eq!(ClassicSurface::default(), ClassicSurface::Plastic); +} diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 96e3333a9..b8e5840c2 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -32,7 +32,7 @@ mod live_paint; mod lookup; mod map; -use cache::RegionCache; +use cache::{BACKGROUND_SEGMENTS, CacheKey, RegionCache}; #[cfg(test)] use live_paint::LpHover; use live_paint::lp_color; @@ -45,7 +45,10 @@ pub use lookup::{ is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, path_contains, shape_builder_shapes, shapes_for, sources, visible_edge_near, visible_face_at, }; -pub use map::{BuilderMap, HATCH, HIGHLIGHT_RED, Hit, Touched, hatch, maps_built, sample_polyline, sorted_roots}; +pub use map::{ + BuilderMap, HATCH, HIGHLIGHT_RED, Hit, MergeGeometry, MergeInput, PreparedMerge, Rest, Touched, hatch, maps_built, merge_geometry, merge_inputs, + merges_computed, sample_polyline, sorted_roots, +}; /// Small, Medium and Large gap lengths in points. pub const GAP_LENGTHS: [f64; 3] = [3.0, 6.0, 12.0]; @@ -169,6 +172,52 @@ pub struct ShapeBuilderTool { /// The pointer went more than [`CLICK_TOL_PX`] from the press during the drag, so the gesture /// is not a click. far: bool, + /// The merge of the last gesture over a large selection, being computed on another thread. + merging: Option, + /// That merge once computed, for `shapeBuilder.merge` ([`Tool::shape_builder_merge`]). + prepared: Option>, + /// A gesture made while the map of a large selection is being built on another thread. + deferred: Option, +} + +/// A Shape Builder gesture made while the map of the selection was being built on another +/// thread: the map key and the inputs it was made over, its pointer events, which [`Tool::poll`] +/// replays once the map is there, and whether the release has come. +struct Deferred { + key: CacheKey, + shapes: Vec, + events: Vec, + released: bool, +} + +/// A Shape Builder merge being computed on another thread: the map key it is for (the document, +/// its revision and the selection), the `shapeBuilder.merge` params that apply it, what it was +/// computed from, and the channel the result arrives on. +struct PendingMerge { + key: CacheKey, + params: Value, + map: Arc, + touched: Touched, + gap: f64, + inputs: Vec, + done: Receiver, +} + +/// Computes [`merge_geometry`] on a new thread; None when no thread starts. +#[cfg(not(target_arch = "wasm32"))] +fn spawn_merge(map: Arc, touched: Touched, gap: f64, inputs: Vec) -> Option> { + let (tx, done) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new().name("vectorcraft-shape-builder-merge".into()).spawn(move || { + // The tool may have dropped the receiver meanwhile (another tool, another document). + let _ = tx.send(merge_geometry(&map, &touched, gap, &inputs)); + }); + spawned.is_ok().then_some(done) +} + +/// The web build has no threads: the merge runs in the command. +#[cfg(target_arch = "wasm32")] +fn spawn_merge(_map: Arc, _touched: Touched, _gap: f64, _inputs: Vec) -> Option> { + None } /// How close (screen px) the pointer must come to a line piece or an edge to delete it. @@ -213,6 +262,132 @@ impl ShapeBuilderTool { alt.then(|| cx.tol(LINE_TOL_PX)) } + /// Starts computing the merge `params` describe on another thread when the map of the + /// selection is current and has more than [`BACKGROUND_SEGMENTS`] segments; `false` when it + /// did not (small selections, the web build), and the command computes it. + fn start_merge(&mut self, cx: &ToolContext, params: &Value) -> bool { + let key = RegionCache::key_for(cx, self.opts.open_filled_as_closed); + if cfg!(target_arch = "wasm32") || params.get("splitStroke").is_some() || self.cache.key.as_ref() != Some(&key) { + return false; + } + let map = Arc::clone(&self.cache.map); + let segments: usize = map.shapes.iter().map(|s| s.path.subpaths.iter().map(|sp| sp.segment_count()).sum::()).sum(); + if segments <= BACKGROUND_SEGMENTS { + return false; + } + // What the command reads from its params, read the same way. + let num = |v: &Value| v.as_f64().unwrap_or(f64::NAN); + let pts: Vec = params["points"].as_array().map(|a| a.iter().map(|q| Point::new(num(&q[0]), num(&q[1]))).collect()).unwrap_or_default(); + let marquee = params["rect"].as_array().filter(|r| r.len() == 4).map(|r| { + let (x, y) = (num(&r[0]), num(&r[1])); + (Point::new(x, y), Point::new(x + num(&r[2]), y + num(&r[3]))) + }); + let piece_tol = (params["erase"] == json!(true)).then(|| params["tolerance"].as_f64().unwrap_or(2.0).clamp(0.0, 1000.0)); + let touched = map.merge_touched(&pts, marquee, piece_tol); + if touched.is_empty() { + return false; + } + let roots = sorted_roots(cx.doc, &cx.selection.objects); + let (_, leaves) = shape_builder_shapes(cx.doc, &roots, self.opts.open_filled_as_closed); + let inputs = merge_inputs(&leaves, self.opts.open_filled_as_closed); + let gap = self.gap(); + let Some(done) = spawn_merge(Arc::clone(&map), touched.clone(), gap, inputs.clone()) else { return false }; + self.merging = Some(PendingMerge { key, params: params.clone(), map, touched, gap, inputs, done }); + true + } + + /// Takes the merge being computed on another thread once it has arrived, or with `wait` once + /// it arrives: the action that applies it, while the document, the selection and its inputs are + /// those it was computed for. When one of them changed (an edit or a selection made other than + /// by a command, which applies the merge first), the merge is dropped at once, without waiting + /// for the thread. A copy of the document (a save stamping its dates) and a new revision that leaves + /// the inputs as they were keep the merge. When the thread failed, the command computes the + /// merge and reports what went wrong. + fn poll_merge(&mut self, cx: &ToolContext, wait: bool) -> Vec { + let Some(mut m) = self.merging.take() else { return vec![] }; + let now = RegionCache::key_for(cx, m.key.3); + if (now.1.0, &now.2) != (m.key.1.0, &m.key.2) { + return vec![]; + } + // A new revision that leaves the selection's inputs as they were (a view setting or a + // preference that redraws the document) keeps the merge. + if now.1 != m.key.1 { + if !self.inputs_are(cx, m.key.3, &m.map.shapes) { + return vec![]; + } + m.key = now; + } + let got = if wait { m.done.recv().map_err(|_| TryRecvError::Disconnected) } else { m.done.try_recv() }; + let geometry = match got { + Ok(g) => Some(g), + Err(TryRecvError::Empty) => { + self.merging = Some(m); + return vec![]; + } + Err(TryRecvError::Disconnected) => None, + }; + self.prepared = geometry.map(|geometry| Arc::new(PreparedMerge { map: m.map, touched: m.touched, gap: m.gap, inputs: m.inputs, geometry })); + vec![Action::Exec("shapeBuilder.merge".into(), m.params)] + } + + /// Is the map of the selection being built on another thread (it is not current yet)? + fn map_building(&mut self, cx: &ToolContext) -> bool { + self.cache.ready(cx, self.opts.open_filled_as_closed).is_none() && self.cache.pending.is_some() + } + + /// Are the inputs of the selection in `cx` `shapes`? The map, or the map being built, is filed + /// under the key of `cx` first when they are its inputs ([`RegionCache::rekey`]). + fn inputs_are(&mut self, cx: &ToolContext, open_filled_as_closed: bool, shapes: &[po::Shape]) -> bool { + let _ = self.cache.rekey(cx, open_filled_as_closed); + let key = RegionCache::key_for(cx, open_filled_as_closed); + if self.cache.key.as_ref() == Some(&key) { + return self.cache.shapes == shapes; + } + if let Some(p) = self.cache.pending.as_ref().filter(|p| p.key == key) { + return p.shapes == shapes; + } + let roots = sorted_roots(cx.doc, &cx.selection.objects); + shape_builder_shapes(cx.doc, &roots, open_filled_as_closed).0 == shapes + } + + /// Is a gesture made while the map was being built released and waiting for the map? + fn deferred_released(&self) -> bool { + self.deferred.as_ref().is_some_and(|d| d.released) + } + + /// Replays the released gesture made while the map was being built once the map is there, or + /// with `wait` once it is built, and with `wait` also waits for the merge it starts: the + /// actions the gesture gives. When the document, the selection or its inputs changed since the + /// gesture, it is dropped; a new revision that leaves them as they were keeps it. + fn replay_deferred(&mut self, cx: &ToolContext, wait: bool) -> Vec { + if !self.deferred_released() { + return vec![]; + } + let ofac = self.opts.open_filled_as_closed; + let now = RegionCache::key_for(cx, ofac); + if let Some(mut d) = self.deferred.take() { + if (d.key.1.0, &d.key.2) != (now.1.0, &now.2) || (d.key.1 != now.1 && !self.inputs_are(cx, ofac, &d.shapes)) { + return vec![]; + } + d.key = now; + self.deferred = Some(d); + } + if wait { + self.cache.get(cx, self.opts.open_filled_as_closed); + } else if self.map_building(cx) { + return vec![]; + } + let Some(d) = self.deferred.take() else { return vec![] }; + let mut acts = vec![]; + for ev in &d.events { + acts.extend(self.pointer(cx, ev)); + } + if wait { + acts.extend(self.poll_merge(cx, true)); + } + acts + } + /// Adds what the drag from `from` to `to` touches, picked as `shapeBuilder.merge` picks it. fn touch(&mut self, cx: &ToolContext, from: Option, to: Point) { let pts: Vec = from.into_iter().chain([to]).collect(); @@ -274,6 +449,8 @@ impl Tool for ShapeBuilderTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; + // The command has taken the merge computed for the last gesture by now. + self.prepared = None; match ev.kind { PointerKind::Move => { self.alt = ev.mods.alt; @@ -282,7 +459,8 @@ impl Tool for ShapeBuilderTool { vec![] } PointerKind::Down => { - if cx.selection.is_empty() { + // While a merge is being computed, a press starts no gesture. + if cx.selection.is_empty() || self.editing() { return vec![]; } self.dragging = true; @@ -291,11 +469,35 @@ impl Tool for ShapeBuilderTool { self.points = vec![p]; self.far = false; self.touched = Touched::default(); + // While the map is being built on another thread, the gesture is recorded and + // replayed once the map is there. + if self.map_building(cx) { + let key = RegionCache::key_for(cx, self.opts.open_filled_as_closed); + let shapes = match self.cache.pending.as_ref().filter(|p| p.key == key) { + Some(p) => p.shapes.clone(), + None => self.cache.inputs(cx, self.opts.open_filled_as_closed).0, + }; + self.deferred = Some(Deferred { key, shapes, events: vec![*ev], released: false }); + return vec![]; + } self.touch(cx, None, p); vec![] } PointerKind::Drag if self.dragging => { self.at = Some(p); + if let Some(d) = &mut self.deferred { + // The drag's line or marquee shows meanwhile. + d.events.push(*ev); + if let Some((a, _)) = self.marquee { + self.marquee = Some((a, p)); + } else if self.opts.straight_line { + self.points.truncate(1); + self.points.push(p); + } else if self.points.last().is_none_or(|q| q.distance(p) >= cx.tol(1.0)) { + self.points.push(p); + } + return vec![]; + } if self.points.first().is_some_and(|s| s.distance(p) > cx.tol(CLICK_TOL_PX)) { self.far = true; } @@ -316,6 +518,13 @@ impl Tool for ShapeBuilderTool { } PointerKind::Up if self.dragging => { self.dragging = false; + if let Some(d) = &mut self.deferred { + d.events.push(*ev); + d.released = true; + self.points.clear(); + self.marquee = None; + return vec![]; + } if self.marquee.is_some() { self.marquee_to(cx, p); } else if self.opts.straight_line { @@ -361,6 +570,10 @@ impl Tool for ShapeBuilderTool { if self.opts.swatches { params["pickColorFrom"] = json!("swatches"); } + // A large selection's merge is computed on another thread; `poll` applies it. + if self.start_merge(cx, ¶ms) { + return vec![]; + } vec![Action::Exec("shapeBuilder.merge".into(), params)] } _ => vec![], @@ -369,10 +582,13 @@ impl Tool for ShapeBuilderTool { fn overlays(&self, cx: &ToolContext) -> Vec { let mut out = vec![]; - let map = &self.cache.map; - let color = if self.alt { HIGHLIGHT_RED } else { HATCH }; - let show: Vec = if self.dragging { - let t = &self.touched; + // While a merge is being computed, what its gesture touched stays highlighted. + let map = self.merging.as_ref().map_or(&self.cache.map, |m| &m.map); + // The mode of the gesture being merged, else the drag's or the hover's. + let alt = self.merging.as_ref().filter(|_| !self.dragging).map_or(self.alt, |m| m.params["erase"] == json!(true)); + let color = if alt { HIGHLIGHT_RED } else { HATCH }; + let touched = if self.dragging { Some(&self.touched) } else { self.merging.as_ref().map(|m| &m.touched) }; + let show: Vec = if let Some(t) = touched { let regions = t.regions.iter().map(|&i| Hit::Region(i)); regions.chain(t.lines.iter().map(|&i| Hit::Line(i))).chain(map.erased_edges(t).into_iter().map(Hit::Edge)).collect() } else { @@ -381,7 +597,7 @@ impl Tool for ShapeBuilderTool { for hit in show { match hit { // Highlight Fill off: merge mode leaves the regions as they are. - Hit::Region(_) if !self.alt && !self.opts.highlight_fill => {} + Hit::Region(_) if !alt && !self.opts.highlight_fill => {} Hit::Region(i) => { if let Some((_, bp, _)) = map.regions.get(i) { out.push(Overlay::Path { path: hatch(bp, cx.tol(4.0)), color, width: 1.0, dashed: false }); @@ -424,7 +640,10 @@ impl Tool for ShapeBuilderTool { fn cursor(&self, _cx: &ToolContext, _p: Point, m: Mods) -> Cursor { // A drag keeps the mode it started in; hovering follows the key. let erase = if self.dragging { self.alt } else { m.alt }; - if erase { + if self.editing() { + // The merge of the last gesture, or the map it waits for, is being computed. + Cursor::Busy + } else if erase { Cursor::ShapeBuilderErase } else if self.stroke.is_some() && !self.dragging { // Over a stroke a click splits. @@ -439,30 +658,53 @@ impl Tool for ShapeBuilderTool { } fn waiting(&self) -> bool { - self.cache.waiting() + self.cache.waiting() || self.merging.is_some() || self.deferred.is_some() } - fn poll(&mut self, cx: &ToolContext, wait: bool) { - if !self.cache.waiting() || self.dragging { - return; + fn editing(&self) -> bool { + self.merging.is_some() || self.deferred_released() + } + + fn finish_edit(&mut self, cx: &ToolContext) -> Vec { + let mut acts = self.replay_deferred(cx, true); + acts.extend(self.poll_merge(cx, true)); + acts + } + + /// Choosing another tool or another document applies the merge being computed first. + fn deactivate(&mut self, cx: &ToolContext) -> Vec { + self.finish_edit(cx) + } + + fn poll(&mut self, cx: &ToolContext, wait: bool) -> Vec { + let mut acts = self.poll_merge(cx, wait); + if !acts.is_empty() || self.dragging { + return acts; } // The map that arrives may be for an earlier revision; the hover then starts the current // one's, which `wait` waits for too. - loop { + while self.cache.waiting() { self.cache.settle(wait); if let Some(p) = self.at { self.hover_at(cx, p); } - if !wait || !self.cache.waiting() { + if !wait { break; } } + // A gesture made while the map was being built goes on once it is there. + acts.extend(self.replay_deferred(cx, wait)); + acts } fn shape_builder_map(&self) -> Option<(Arc, f64)> { self.cache.key.is_some().then(|| (Arc::clone(&self.cache.map), self.cache.gap)) } + fn shape_builder_merge(&self) -> Option> { + self.prepared.clone() + } + /// With Pick Color From Color Swatches, the Left and Right arrow keys pick the color swatch /// before or after the current fill. Escape during a drag cancels it: the release after it /// merges and deletes nothing. @@ -471,13 +713,16 @@ impl Tool for ShapeBuilderTool { } fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { - if !self.claims_key(cx, key) { + // While a merge is being computed, the arrows leave the current fill it takes as it was. + if !self.claims_key(cx, key) || self.editing() { return vec![]; } if key == ToolKey::Escape { self.dragging = false; self.points.clear(); self.touched = Touched::default(); + // A gesture recorded while the map is being built is canceled with it. + self.deferred = None; (self.marquee, self.hover, self.far) = (None, None, false); return vec![]; } diff --git a/crates/tools/src/builder/cache.rs b/crates/tools/src/builder/cache.rs index f34a29000..4b38fe1b7 100644 --- a/crates/tools/src/builder/cache.rs +++ b/crates/tools/src/builder/cache.rs @@ -8,18 +8,21 @@ pub(super) type CacheKey = (usize, (u64, u64), Vec, bool); /// Hovering builds the map of inputs with more segments than this on another thread /// ([`RegionCache::ready`]); smaller maps are built on the calling thread. -const BACKGROUND_SEGMENTS: usize = 128; +pub(super) const BACKGROUND_SEGMENTS: usize = 128; -/// A map being built on another thread: the key it is for, the drawn outlines of its inputs, and -/// the channel it arrives on. -struct Pending { - key: CacheKey, +/// A map being built on another thread: the key it is for, its inputs and their drawn outlines, +/// and the channel it arrives on. +pub(super) struct Pending { + pub(super) key: CacheKey, + pub(super) shapes: Vec, outlines: Vec, done: Receiver, } /// The planar map of the current selection, cached by document identity, revision, selection and -/// whether filled open paths count as closed. +/// whether filled open paths count as closed. When only the document's identity or revision +/// changed and the selection's inputs did not (a view setting, a preference, a copy of the +/// document), the map is kept under the new key ([`RegionCache::rekey`]). #[derive(Default)] pub(super) struct RegionCache { pub(super) key: Option, @@ -31,8 +34,12 @@ pub(super) struct RegionCache { pub(super) gap: f64, /// The outlines of the Shape Builder's inputs as they are drawn (see [`BuilderMap::stroke_at`]). pub(super) outlines: Vec, + /// The inputs the map was built from. + pub(super) shapes: Vec, + /// The last key whose inputs [`Self::rekey`] compared with the map's. + compared: Option, /// The map being built on another thread, if any. - pending: Option, + pub(super) pending: Option, /// The key of the last map whose build on another thread failed. failed: Option, /// How many times the map has been built. @@ -76,13 +83,13 @@ impl RegionCache { Self { live: true, ..Default::default() } } - fn key_for(cx: &ToolContext, open_filled_as_closed: bool) -> CacheKey { + pub(super) fn key_for(cx: &ToolContext, open_filled_as_closed: bool) -> CacheKey { (cx.doc as *const Document as usize, cx.revision, cx.selection.objects.clone(), open_filled_as_closed) } /// The inputs of the selection's map, back → front, and their drawn outlines (Shape Builder /// only). - fn inputs(&self, cx: &ToolContext, open_filled_as_closed: bool) -> (Vec, Vec) { + pub(super) fn inputs(&self, cx: &ToolContext, open_filled_as_closed: bool) -> (Vec, Vec) { let roots = sorted_roots(cx.doc, &cx.selection.objects); if self.live { (shapes_for(cx.doc, &roots).0, vec![]) @@ -93,8 +100,9 @@ impl RegionCache { } } - fn install(&mut self, key: CacheKey, map: BuilderMap, outlines: Vec) { + fn install(&mut self, key: CacheKey, map: BuilderMap, shapes: Vec, outlines: Vec) { self.map = Arc::new(map); + self.shapes = shapes; self.outlines = outlines; self.key = Some(key); #[cfg(test)] @@ -122,8 +130,12 @@ impl RegionCache { pub(super) fn get(&mut self, cx: &ToolContext, open_filled_as_closed: bool) -> &BuilderMap { let key = Self::key_for(cx, open_filled_as_closed); if self.key.as_ref() != Some(&key) { - let built = self.pending.take().filter(|p| p.key == key).and_then(|p| Some((p.done.recv().ok()?, p.outlines))); - let (map, outlines) = match built { + let inputs = self.rekey(cx, open_filled_as_closed); + if self.key.as_ref() == Some(&key) { + return &self.map; + } + let built = self.pending.take().filter(|p| p.key == key).and_then(|p| Some((p.done.recv().ok()?, p.shapes, p.outlines))); + let (map, shapes, outlines) = match built { Some(b) => { #[cfg(test)] { @@ -132,15 +144,45 @@ impl RegionCache { b } None => { - let (shapes, outlines) = self.inputs(cx, open_filled_as_closed); - (build_map(shapes, self.live, self.gap), outlines) + let (shapes, outlines) = inputs.unwrap_or_else(|| self.inputs(cx, open_filled_as_closed)); + (build_map(shapes.clone(), self.live, self.gap), shapes, outlines) } }; - self.install(key, map, outlines); + self.install(key, map, shapes, outlines); } &self.map } + /// When the map, or the map being built, is for the selection of `cx` but for another revision + /// or address of the document, and the selection's inputs and their outlines are those it is + /// made from, files it under the key of `cx`: nothing it is made from changed. Returns the + /// inputs it computed when that did not file a map (they changed), for the caller to build + /// from. + pub(super) fn rekey(&mut self, cx: &ToolContext, open_filled_as_closed: bool) -> Option<(Vec, Vec)> { + let key = Self::key_for(cx, open_filled_as_closed); + let other = |k: &CacheKey| *k != key && (&k.2, k.3) == (&key.2, key.3); + if self.pending.as_ref().is_some_and(|p| p.key == key) { + return None; + } + let installed = self.key.as_ref().is_some_and(other); + let building = self.pending.as_ref().is_some_and(|p| other(&p.key)); + // The inputs are compared once for each key. + if (!installed && !building) || self.compared.as_ref() == Some(&key) { + return None; + } + self.compared = Some(key.clone()); + let (shapes, outlines) = self.inputs(cx, open_filled_as_closed); + if installed && self.shapes == shapes && self.outlines == outlines { + self.key = Some(key); + return None; + } + if let Some(p) = self.pending.as_mut().filter(|p| other(&p.key) && p.shapes == shapes && p.outlines == outlines) { + p.key = key; + return None; + } + Some((shapes, outlines)) + } + /// Takes the map being built on another thread once it has arrived, or with `wait` once it /// arrives, as the map for the key it was built for. pub(super) fn settle(&mut self, wait: bool) { @@ -152,7 +194,7 @@ impl RegionCache { { self.background += 1; } - self.install(p.key, map, p.outlines); + self.install(p.key, map, p.shapes, p.outlines); } // The build failed (a bug in the planar map): hovering does not build it again, and a // gesture builds it on the calling thread, which reports the failure. @@ -171,21 +213,25 @@ impl RegionCache { if self.key.as_ref() == Some(&key) { return Some(&self.map); } + let inputs = self.rekey(cx, open_filled_as_closed); + if self.key.as_ref() == Some(&key) { + return Some(&self.map); + } if self.pending.is_some() || self.failed.as_ref() == Some(&key) { return None; } - let (shapes, outlines) = self.inputs(cx, open_filled_as_closed); + let (shapes, outlines) = inputs.unwrap_or_else(|| self.inputs(cx, open_filled_as_closed)); let segments: usize = shapes.iter().map(|s| s.path.subpaths.iter().map(|sp| sp.segment_count()).sum::()).sum(); if segments > BACKGROUND_SEGMENTS && cfg!(not(target_arch = "wasm32")) { - if let Some(done) = spawn_build(shapes, self.live, self.gap) { - self.pending = Some(Pending { key, outlines, done }); + if let Some(done) = spawn_build(shapes.clone(), self.live, self.gap) { + self.pending = Some(Pending { key, shapes, outlines, done }); return None; } // No thread started: build here. return Some(self.get(cx, open_filled_as_closed)); } - let map = build_map(shapes, self.live, self.gap); - self.install(key, map, outlines); + let map = build_map(shapes.clone(), self.live, self.gap); + self.install(key, map, shapes, outlines); Some(&self.map) } diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index f4c1ffd85..08fca1432 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -282,9 +282,9 @@ impl Tool for LivePaintBucketTool { self.cache.waiting() } - fn poll(&mut self, cx: &ToolContext, wait: bool) { + fn poll(&mut self, cx: &ToolContext, wait: bool) -> Vec { if !self.cache.waiting() { - return; + return vec![]; } // As the Shape Builder's: the map that arrives may be for an earlier revision. Over a part // of a Live Paint group the hover takes no map. @@ -297,6 +297,7 @@ impl Tool for LivePaintBucketTool { break; } } + vec![] } } diff --git a/crates/tools/src/builder/lookup.rs b/crates/tools/src/builder/lookup.rs index 99aeeb7aa..5e85d9339 100644 --- a/crates/tools/src/builder/lookup.rs +++ b/crates/tools/src/builder/lookup.rs @@ -284,4 +284,6 @@ pub(super) fn faces_of(regions: Vec) -> Vec { thread_local! { /// The Shape Builder maps built on this thread ([`maps_built`]). pub(super) static MAPS_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// The Shape Builder merges computed on this thread ([`merges_computed`]). + pub(super) static MERGES_COMPUTED: std::cell::Cell = const { std::cell::Cell::new(0) }; } diff --git a/crates/tools/src/builder/map.rs b/crates/tools/src/builder/map.rs index b1ccfa949..74ec31f69 100644 --- a/crates/tools/src/builder/map.rs +++ b/crates/tools/src/builder/map.rs @@ -1,4 +1,4 @@ -use super::lookup::{Face, MAPS_BUILT, faces_of, outline_snap, shape_builder_shapes}; +use super::lookup::{Face, MAPS_BUILT, MERGES_COMPUTED, faces_of, node_outline, outline_snap, shape_builder_shapes}; use super::*; /// Shape Builder hover hatch colour (neutral grey) and Live Paint highlight red. @@ -12,6 +12,12 @@ pub fn maps_built() -> usize { MAPS_BUILT.with(std::cell::Cell::get) } +/// How many Shape Builder merges ([`merge_geometry`]) this thread has computed, for tests that +/// count them. +pub fn merges_computed() -> usize { + MERGES_COMPUTED.with(std::cell::Cell::get) +} + /// The Shape Builder's planar map of a selection. #[derive(Default)] pub struct BuilderMap { @@ -345,6 +351,112 @@ impl BuilderMap { let bounds_one = |(e, _): &(po::Shape, Rect)| self.sides(e).iter().any(|r| r.is_some_and(|r| touched.regions.contains(&r))); touched.edges.iter().copied().filter(|&k| self.edges.get(k).is_some_and(|e| !bounds_one(e))).collect() } + + /// What `shapeBuilder.merge` touches: the regions the marquee from `marquee.0` to `marquee.1` + /// reaches, then what the drag along `pts` touches ([`Self::builder_hits_along`]), and the + /// edges that deletes ([`Self::erased_edges`]). + pub fn merge_touched(&self, pts: &[Point], marquee: Option<(Point, Point)>, piece_tol: Option) -> Touched { + let mut touched = Touched { regions: marquee.map(|(a, b)| self.regions_in_marquee(a, b)).unwrap_or_default(), ..Default::default() }; + touched.extend(&self.builder_hits_along(pts, piece_tol)); + touched.edges = self.erased_edges(&touched); + touched + } +} + +/// What a merge needs of each input besides the map: its outline as drawn ([`node_outline`]) and +/// whether its fill closes its open subpaths (Consider Open Filled Paths as Closed, and a fill). +#[derive(Clone, Debug, PartialEq)] +pub struct MergeInput { + pub outline: Option, + pub fill_closes: bool, +} + +/// The [`MergeInput`] of each of `leaves`. +pub fn merge_inputs(leaves: &[&Node], open_filled_as_closed: bool) -> Vec { + leaves + .iter() + .map(|l| MergeInput { outline: node_outline(l).map(|o| o.0), fill_closes: open_filled_as_closed && !l.appearance.fill_paint().is_none() }) + .collect() +} + +/// What is left of an input a merge or a delete changes: its faces not touched, united +/// (`united`), or its own outline, with the deleted edges cut out of it. +#[derive(Clone, Debug, PartialEq)] +pub struct Rest { + pub path: PathData, + pub united: bool, +} + +/// The new shapes of a Shape Builder merge or delete ([`merge_geometry`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MergeGeometry { + /// The union of the touched regions (empty when none is touched). + pub union: PathData, + /// For each closed input, what goes in its place when the gesture changes it. + pub rests: Vec>, +} + +/// The new shapes of a gesture over `map` that touched `touched`, closing gaps up to `gap`: the +/// union of the touched regions, and for each closed input that has a touched region or a deleted +/// edge, what is left of it. An input a touched region covers keeps its other faces, united +/// (they share their edges with the merged faces exactly, where subtracting the merged shape left +/// slivers of the outline along them, #569; a sliver goes in only where the input covers it and +/// the merged shape does not take it in); then each deleted edge is cut out of it. Inputs with +/// pieces of open paths (`map.lines`) are left to the caller. +pub fn merge_geometry(map: &BuilderMap, touched: &Touched, gap: f64, inputs: &[MergeInput]) -> MergeGeometry { + MERGES_COMPUTED.with(|n| n.set(n.get() + 1)); + let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect(); + let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect(); + let faces = po::FaceMerger::with_gap(&map.shapes, gap); + let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) }; + let rests = map + .shapes + .iter() + .enumerate() + .map(|(i, sh)| { + let input = inputs.get(i)?; + let in_union = picked.iter().any(|r| r.sources.contains(&i)); + if map.lines.iter().any(|(l, _)| l.key == i as u64) || (!in_union && cuts.is_empty()) { + return None; + } + let mut rest = if in_union { + let kept: Vec<&po::Region> = map + .regions + .iter() + .enumerate() + .filter(|(k, (r, ..))| r.sources.contains(&i) && !touched.regions.contains(k)) + .map(|(_, (r, ..))| r) + .collect(); + if kept.is_empty() { PathData::default() } else { faces.merge_rest(i, &kept, &picked) } + } else { + input.outline.clone().unwrap_or_else(|| sh.path.clone()) + }; + // Edges come back from the planar map within a few hundredths of the outlines they + // follow, whatever else is selected. + let cut_tol = outline_snap(&sh.path); + let mut cut = false; + for piece in &cuts { + if let Some(r) = po::cut_out(&rest, piece, input.fill_closes, cut_tol) { + rest = r; + cut = true; + } + } + (in_union || cut).then_some(Rest { path: rest, united: in_union }) + }) + .collect(); + MergeGeometry { union, rests } +} + +/// A merge computed on another thread for a Shape Builder click or drag +/// ([`Tool::shape_builder_merge`]): the map, what the gesture touched and the inputs it was +/// computed from, and the result. `shapeBuilder.merge` takes the result when it would compute the +/// same. +pub struct PreparedMerge { + pub map: Arc, + pub touched: Touched, + pub gap: f64, + pub inputs: Vec, + pub geometry: MergeGeometry, } /// The regions, line pieces and edges a Shape Builder gesture has touched, in the order touched. diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index fc3ed294c..4e3919db8 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -132,20 +132,34 @@ fn shape_builder_builds_its_map_once_for_hover_alt_hover_and_a_merge() { // The merge left the document as it was (it failed, say): the map still fits it. t.pointer(&cx1, &ev(PointerKind::Move, 75.0, 75.0)); assert_eq!(t.cache.builds, 1, "a hover after a merge that changed nothing"); + // A new revision of the same art (a view setting, a preference) keeps the map. let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; t.pointer(&cx2, &ev(PointerKind::Move, 75.0, 75.0)); - assert_eq!(t.cache.builds, 2, "a hover on a new revision"); + assert_eq!(t.cache.builds, 1, "a hover on a new revision of the same art"); + assert!(matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover); + // The same objects, one of them moved. + let mut moved = d.clone(); + let second = s.objects[1]; + moved.node_mut(second).unwrap().transform(vectorcraft_geom::Affine::translate((10.0, 0.0)), false); + let cx3 = ToolContext { revision: (1, 3), ..cx(&moved, &s, &p) }; + t.pointer(&cx3, &ev(PointerKind::Move, 75.0, 75.0)); + assert_eq!(t.cache.builds, 2, "a hover on a new revision of other art"); } /// A row of `n` selected squares 20 pt wide, each overlapping the next by half: 4 segments each. fn square_row(n: usize) -> (Document, Selection) { + square_row_at(n, 0.0) +} + +/// [`square_row`] with the squares' tops at `y`: the same objects, moved. +fn square_row_at(n: usize, y: f64) -> (Document, Selection) { let mut d = Document::new(500.0, 500.0); let l = d.layers[0].id; let mut s = Selection::default(); for k in 0..n { let x = k as f64 * 10.0; let id = d.alloc_id(); - d.insert(Some(l), k, Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 20.0, 20.0)), Appearance::default_art())).unwrap(); + d.insert(Some(l), k, Node::path(id, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), Appearance::default_art())).unwrap(); s.add(id); } (d, s) @@ -176,9 +190,10 @@ fn shape_builder_hover_builds_a_large_map_on_another_thread() { assert_eq!((t.cache.builds, t.cache.background), (1, 1)); assert_eq!(t.overlays(&cx1).len(), 2, "the region's hatch and outline"); // A new revision while the map of the one before it is being built: only the newer one's - // map is used. - let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; - let cx3 = ToolContext { revision: (1, 3), ..cx(&d, &s, &p) }; + // map is used. Each revision moves the squares down 1 pt. + let rows: Vec = (0..7).map(|k| square_row_at(40, f64::from(k)).0).collect(); + let cx2 = ToolContext { revision: (1, 2), ..cx(&rows[2], &s, &p) }; + let cx3 = ToolContext { revision: (1, 3), ..cx(&rows[3], &s, &p) }; t.pointer(&cx2, &hover); t.pointer(&cx3, &hover); poll_until_ready(&mut t, &cx3); @@ -186,29 +201,265 @@ fn shape_builder_hover_builds_a_large_map_on_another_thread() { assert_eq!((t.cache.builds, t.cache.background), (3, 3), "the earlier revision's map, then the newer one's"); assert_eq!(t.cache.key.as_ref().map(|k| k.1), Some((1, 3))); // A host without frames waits for the map. - let cx4 = ToolContext { revision: (1, 4), ..cx(&d, &s, &p) }; + let cx4 = ToolContext { revision: (1, 4), ..cx(&rows[4], &s, &p) }; t.pointer(&cx4, &hover); assert!(t.waiting() && t.hover.is_none()); t.poll(&cx4, true); assert!(!t.waiting()); assert!(matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover); assert_eq!((t.cache.builds, t.cache.background), (4, 4)); - // A press while the map is being built waits for it, and the drag merges. - let cx5 = ToolContext { revision: (1, 5), ..cx(&d, &s, &p) }; + // A drag while the map is being built is replayed once it is there, and merges (on + // another thread, see `shape_builder_merges_a_large_selection_on_another_thread` and + // `shape_builder_gesture_waits_for_the_map_without_holding_the_caller`). + let cx5 = ToolContext { revision: (1, 5), ..cx(&rows[5], &s, &p) }; t.pointer(&cx5, &hover); t.pointer(&cx5, &ev(PointerKind::Down, 15.0, 10.0)); t.pointer(&cx5, &ev(PointerKind::Drag, 35.0, 10.0)); - let acts = t.pointer(&cx5, &ev(PointerKind::Up, 35.0, 10.0)); - assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}"); + assert!(t.pointer(&cx5, &ev(PointerKind::Up, 35.0, 10.0)).is_empty()); + let acts = t.poll(&cx5, true); assert_eq!((t.cache.builds, t.cache.background), (5, 5)); + assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}"); assert!(!t.waiting()); // Changing an option forgets a map being built with the old one. - let cx6 = ToolContext { revision: (1, 6), ..cx(&d, &s, &p) }; + let cx6 = ToolContext { revision: (1, 6), ..cx(&rows[6], &s, &p) }; t.pointer(&cx6, &hover); t.set_option("gapDetection", &json!(true)); assert!(!t.waiting()); } +/// Polls `t` until it stops waiting, as the desktop app does each frame, and returns the +/// actions the polls gave. +fn poll_actions(t: &mut dyn Tool, cx: &ToolContext) -> Vec { + let start = std::time::Instant::now(); + let mut acts = vec![]; + while t.waiting() { + assert!(start.elapsed() < std::time::Duration::from_secs(60), "the tool is still waiting"); + std::thread::sleep(std::time::Duration::from_millis(2)); + acts.extend(t.poll(cx, false)); + } + acts +} + +/// A Shape Builder over `square_row(40)` (160 segments) that has merged nothing, with its map, +/// after a click at (15, 10) that started a merge on another thread. +fn clicked_row(cx: &ToolContext) -> ShapeBuilderTool { + let mut t = ShapeBuilderTool::default(); + t.pointer(cx, &ev(PointerKind::Move, 15.0, 10.0)); + poll_until_ready(&mut t, cx); + t.pointer(cx, &ev(PointerKind::Down, 15.0, 10.0)); + assert!(t.pointer(cx, &ev(PointerKind::Up, 15.0, 10.0)).is_empty(), "the release returns before the merge is computed"); + assert!(t.waiting() && t.editing()); + t +} + +#[test] +fn shape_builder_merges_a_large_selection_on_another_thread() { + let (d, s) = square_row(40); + let p = paint(); + let cx = cx(&d, &s, &p); + let computed = merges_computed(); + let mut t = clicked_row(&cx); + assert_eq!(t.cursor(&cx, Point::new(15.0, 10.0), Mods::default()), Cursor::Busy); + assert_eq!(t.overlays(&cx).len(), 2, "the clicked region's hatch and outline stay"); + // A press while the merge is being computed starts no gesture. + assert!(t.pointer(&cx, &ev(PointerKind::Down, 35.0, 10.0)).is_empty()); + assert!(!t.busy()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 35.0, 10.0)).is_empty()); + let acts = poll_actions(&mut t, &cx); + assert_eq!(merges_computed(), computed, "the merge was computed on another thread"); + let [Action::Exec(id, params)] = acts.as_slice() else { panic!("{acts:?}") }; + assert_eq!((id.as_str(), ¶ms["points"]), ("shapeBuilder.merge", &json!([[15.0, 10.0]]))); + assert!(!t.waiting() && !t.editing()); + assert_eq!(t.cursor(&cx, Point::new(15.0, 10.0), Mods::default()), Cursor::ShapeBuilder); + let m = t.shape_builder_merge().expect("the merge the command takes"); + assert!(Arc::ptr_eq(&m.map, &t.cache.map)); + assert_eq!((m.touched.regions.len(), m.touched.lines.len(), m.touched.edges.len()), (1, 0, 0)); + assert!(!m.geometry.union.is_empty()); + assert_eq!(m.geometry, merge_geometry(&m.map, &m.touched, m.gap, &m.inputs)); + assert_eq!(m.geometry.rests.iter().flatten().count(), 2, "the two squares over the region change"); + // The next event lets it go. + t.pointer(&cx, &ev(PointerKind::Move, 15.0, 10.0)); + assert!(t.shape_builder_merge().is_none()); +} + +#[test] +fn shape_builder_drops_a_merge_the_art_changed_under() { + let (d, s) = square_row(40); + let p = paint(); + let mut fewer = Selection::default(); + fewer.set(s.objects.iter().skip(1).copied()); + let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; + let (moved, _) = square_row_at(40, 5.0); + let later = [ + // An edit or an undo: the squares moved. + ToolContext { revision: (1, 2), ..cx(&moved, &s, &p) }, + // Another document. + ToolContext { revision: (2, 1), ..cx(&d, &s, &p) }, + // Another selection. + ToolContext { revision: (1, 1), ..cx(&d, &fewer, &p) }, + ]; + for (k, cx2) in later.iter().enumerate() { + let mut t = clicked_row(&cx1); + // Dropped at the first poll, before the thread is done. + assert!(t.poll(cx2, false).is_empty(), "change {k}: the merge is not applied"); + assert!(!t.editing() && !t.waiting() && t.shape_builder_merge().is_none(), "change {k}"); + } + // Unchanged, the same wait applies it. + let mut t = clicked_row(&cx1); + assert_eq!(t.poll(&cx1, true).len(), 1); + // A copy of the document at the same revision (a save stamps its dates on a copy), and + // finishing the edit before a command, apply it too. + let copy = d.clone(); + let cx3 = ToolContext { revision: (1, 1), ..cx(©, &s, &p) }; + let mut t = clicked_row(&cx1); + assert_eq!(t.finish_edit(&cx3).len(), 1); + assert!(!t.editing() && t.shape_builder_merge().is_some()); + // Switching away applies it. + let mut t = clicked_row(&cx1); + assert_eq!(t.deactivate(&cx1).len(), 1); + assert!(!t.editing()); +} + +/// A new revision of the document that leaves the selection's inputs as they were (a view +/// setting or a preference that redraws it) keeps the merge being computed, the gesture made +/// while the map is being built and the map itself: the merge applies with the map it was +/// computed over, and no map is built again. +#[test] +fn shape_builder_keeps_its_work_over_a_new_revision_of_the_same_art() { + let (d, s) = square_row(40); + let p = paint(); + let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; + let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; + // A merge being computed. + let computed = merges_computed(); + let mut t = clicked_row(&cx1); + let acts = poll_actions(&mut t, &cx2); + assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}"); + assert_eq!(merges_computed(), computed, "the merge computed on another thread"); + let m = t.shape_builder_merge().expect("the merge the command takes"); + assert!(Arc::ptr_eq(&m.map, &t.cache.map), "the map the merge was computed over"); + assert_eq!(t.cache.key.as_ref().map(|k| k.1), Some((1, 2))); + assert_eq!(t.cache.builds, 1, "no map built again"); + // Finishing it before a command too. + let mut t = clicked_row(&cx1); + assert_eq!(t.finish_edit(&cx2).len(), 1); + // A hover over the new revision builds no map. + let mut t = ShapeBuilderTool::default(); + let hover = ev(PointerKind::Move, 15.0, 10.0); + t.pointer(&cx1, &hover); + poll_until_ready(&mut t, &cx1); + t.pointer(&cx2, &hover); + assert!(!t.waiting() && matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover); + assert_eq!(t.cache.builds, 1); + // A map being built, and a gesture made meanwhile. + let drag = [ev(PointerKind::Down, 15.0, 10.0), ev(PointerKind::Drag, 35.0, 10.0), ev(PointerKind::Up, 35.0, 10.0)]; + let mut ready = ShapeBuilderTool::default(); + ready.pointer(&cx1, &hover); + poll_until_ready(&mut ready, &cx1); + for e in &drag { + ready.pointer(&cx1, e); + } + let want = poll_actions(&mut ready, &cx1); + let mut t = ShapeBuilderTool::default(); + t.pointer(&cx1, &hover); + for e in &drag { + assert!(t.pointer(&cx1, e).is_empty()); + } + assert!(t.editing() && t.cache.builds == 0, "the gesture waits for the map"); + assert_eq!(poll_actions(&mut t, &cx2), want); + assert_eq!((t.cache.builds, t.cache.background), (1, 1), "the map built for the first revision"); + // Finishing it before a command waits for the map being built rather than building it again. + let mut t = ShapeBuilderTool::default(); + t.pointer(&cx1, &hover); + for e in &drag { + t.pointer(&cx1, e); + } + assert_eq!(t.finish_edit(&cx2), want); + assert_eq!((t.cache.builds, t.cache.background), (1, 1), "the map built on the other thread"); + // Another document with the same art drops the merge. + let cx3 = ToolContext { revision: (2, 1), ..cx(&d, &s, &p) }; + let mut t = clicked_row(&cx1); + assert!(t.poll(&cx3, false).is_empty()); + assert!(!t.editing()); +} + +/// A press while the map of a large selection is being built on another thread does not wait +/// for it: the gesture is recorded, each event returns at once, and the polls replay it once +/// the map is there, giving the merge the same gesture gives over the map. Finishing the edit +/// waits for the map and the merge; another revision of other art drops the gesture. +#[test] +fn shape_builder_gesture_waits_for_the_map_without_holding_the_caller() { + let (d, s) = square_row(200); + let p = paint(); + let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; + let hover = ev(PointerKind::Move, 15.0, 10.0); + let drag = [ev(PointerKind::Down, 15.0, 10.0), ev(PointerKind::Drag, 35.0, 10.0), ev(PointerKind::Up, 35.0, 10.0)]; + let mut ready = ShapeBuilderTool::default(); + ready.pointer(&cx1, &hover); + poll_until_ready(&mut ready, &cx1); + for e in &drag { + ready.pointer(&cx1, e); + } + let want = poll_actions(&mut ready, &cx1); + assert!(matches!(want.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{want:?}"); + let made_while_building = || { + let mut t = ShapeBuilderTool::default(); + t.pointer(&cx1, &hover); + for e in &drag { + assert!(t.pointer(&cx1, e).is_empty()); + } + assert_eq!(t.cache.builds, 0, "the gesture did not wait for the map"); + assert!(t.editing() && t.waiting()); + t + }; + let mut t = made_while_building(); + assert_eq!(t.cursor(&cx1, Point::new(35.0, 10.0), Mods::default()), Cursor::Busy); + // A press before the gesture is replayed starts nothing. + assert!(t.pointer(&cx1, &ev(PointerKind::Down, 55.0, 10.0)).is_empty()); + assert!(t.pointer(&cx1, &ev(PointerKind::Up, 55.0, 10.0)).is_empty()); + assert_eq!(poll_actions(&mut t, &cx1), want); + assert!(!t.editing() && t.shape_builder_merge().is_some()); + // Finishing the edit before a command waits for both. + let mut t = made_while_building(); + assert_eq!(t.finish_edit(&cx1), want); + // Another revision of other art before the map is there drops the gesture. + let mut t = made_while_building(); + let (moved, _) = square_row_at(200, 5.0); + let cx2 = ToolContext { revision: (1, 2), ..cx(&moved, &s, &p) }; + assert!(t.poll(&cx2, false).is_empty()); + assert!(!t.editing()); +} + +/// Escape during a drag made while the map of a large selection is being built cancels the +/// recorded gesture too: the release and the polls after it merge nothing, the tool stops +/// waiting once the map is there, and the next drag merges on its own. +#[test] +fn shape_builder_escape_cancels_a_gesture_made_while_the_map_is_built() { + let (d, s) = square_row(200); + let p = paint(); + let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; + let mut t = ShapeBuilderTool::default(); + t.pointer(&cx1, &ev(PointerKind::Move, 15.0, 10.0)); + assert!(t.pointer(&cx1, &ev(PointerKind::Down, 15.0, 10.0)).is_empty()); + assert!(t.pointer(&cx1, &ev(PointerKind::Drag, 35.0, 10.0)).is_empty()); + assert_eq!(t.cache.builds, 0, "the gesture did not wait for the map"); + assert!(t.claims_key(&cx1, ToolKey::Escape)); + assert!(t.key(&cx1, ToolKey::Escape, Mods::default()).is_empty()); + assert!(t.pointer(&cx1, &ev(PointerKind::Up, 35.0, 10.0)).is_empty()); + assert!(poll_actions(&mut t, &cx1).is_empty(), "the canceled gesture merges nothing"); + assert!(!t.waiting() && !t.editing()); + // The next drag merges what it touches, with no event of the canceled one. + let mut fresh = ShapeBuilderTool::default(); + fresh.pointer(&cx1, &ev(PointerKind::Move, 55.0, 10.0)); + poll_until_ready(&mut fresh, &cx1); + let next = [ev(PointerKind::Down, 55.0, 10.0), ev(PointerKind::Drag, 75.0, 10.0), ev(PointerKind::Up, 75.0, 10.0)]; + for e in &next { + fresh.pointer(&cx1, e); + t.pointer(&cx1, e); + } + assert_eq!(poll_actions(&mut t, &cx1), poll_actions(&mut fresh, &cx1)); +} + #[test] fn shape_builder_hover_builds_a_small_map_at_once() { let (d, s) = square_row(3); @@ -1110,9 +1361,15 @@ fn bucket_keeps_its_map_while_the_document_is_unchanged() { // The fill left the document as it was: the map still fits it. t.pointer(&cx1, &ev(PointerKind::Move, 25.0, 50.0)); assert_eq!(t.cache.builds, 1, "a hover after a fill that changed nothing"); + // A new revision of the same art keeps the map; one with an object moved builds it again. let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; t.pointer(&cx2, &ev(PointerKind::Move, 25.0, 50.0)); - assert_eq!(t.cache.builds, 2, "a hover on a new revision"); + assert_eq!(t.cache.builds, 1, "a hover on a new revision of the same art"); + let mut moved = d.clone(); + moved.node_mut(b).unwrap().transform(vectorcraft_geom::Affine::translate((10.0, 0.0)), false); + let cx3 = ToolContext { revision: (1, 3), ..cx(&moved, &s, &p) }; + t.pointer(&cx3, &ev(PointerKind::Move, 25.0, 50.0)); + assert_eq!(t.cache.builds, 2, "a hover on a new revision of other art"); } #[test] diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index 835303b44..054db258d 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -1,5 +1,15 @@ use super::{Document, NodeId, Paint, Point, Selection, Unit, Vec2, distort, select, xform}; +/// Which objects a Selection-tool marquee reaches before E reverses its rule for this drag. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MarqueeMode { + #[default] + Touching, + Enclosed, + /// Screen-right takes enclosed objects; screen-left takes touched objects. + Directional, +} + /// Paint defaults for new art (the fill/stroke proxy) and the rest of the new-art template. #[derive(Clone, Debug, PartialEq)] pub struct PaintDefaults { @@ -120,6 +130,8 @@ pub struct ToolContext<'a> { pub selection: &'a Selection, /// Screen pixels per document point. pub zoom: f64, + /// Selection-tool marquee rule; other selection tools keep their own rules. + pub marquee_mode: MarqueeMode, pub isolation: Option, pub paint: &'a PaintDefaults, pub outline: bool, @@ -176,6 +188,10 @@ pub struct ToolContext<'a> { /// Clipboard Handling → When pasting text: Keep Plain Text. Text pasted while typing takes /// the style at the caret, even the text the Type tool copied with its formatting. pub paste_plain_text: bool, + /// The text given to [`crate::Tool::text_input`] was typed on the keyboard (a key or an IME + /// commit), not inserted from the Glyphs panel or a paste: a lone typed `”` may then take the + /// document's opening quote ([`vectorcraft_doc::Quotes::typed_as_straight`]). + pub typed_from_keyboard: bool, /// View → Hide Slices: the Slice Selection tool can't pick hidden slices. pub slices_hidden: bool, /// View → Lock Slices: the Slice Selection tool leaves locked slices alone. @@ -223,6 +239,9 @@ pub struct ToolContext<'a> { /// Type → Fill New Type Objects With Placeholder Text: type the Type tools place starts with /// placeholder text, selected. pub placeholder_text: bool, + /// Type → Start Lists From Typed Markers: a list marker and a space typed at the start of a + /// paragraph start a list ([`crate::text::TypeTool`]). + pub typed_lists: bool, /// Smart Guides → Color: the smart guides' lines and labels (RGB). pub smart_guide_color: [u8; 3], /// Smart Guides → Alignment Guides: the lines along the edges and centres the art lines up diff --git a/crates/tools/src/corners.rs b/crates/tools/src/corners.rs index 5a7a49e1c..c60c6a748 100644 --- a/crates/tools/src/corners.rs +++ b/crates/tools/src/corners.rs @@ -257,7 +257,7 @@ pub struct CornerDrag { } /// Direct Selection: the drawn anchors of a widget's corner, which a click on the widget selects -/// (Shift: toggles), as a click on the corner's anchor does. +/// (Shift: toggles, unless every corner is selected), as a click on the corner's anchor does. #[derive(Clone, Debug)] struct CornerClick { id: NodeId, @@ -282,7 +282,13 @@ impl CornerDrag { let path = widgets.paths.get(widget.0)?; let corner = path.widgets.get(widget.1)?.corner.index; let lc = cx.doc.node(path.id).and_then(LiveCorners::of)?; - Some(CornerClick { id: path.id, anchors: lc.anchors_of(&[corner].into()).into_iter().collect(), shift: ev.mods.shift }) + // While every corner is selected (the path selected whole), a Shift-click picks + // this corner alone, as the first click of "click one widget, then Shift-click the + // others" does: toggling it would deselect it and leave the rest (#5188). Selected + // anchors that are no corner show every widget too, but select no corner: there a + // Shift-click adds the corner. + let every = cx.selection.partial(path.id).is_none_or(|anchors| lc.all().is_subset(&lc.corners_of(anchors))); + Some(CornerClick { id: path.id, anchors: lc.anchors_of(&[corner].into()).into_iter().collect(), shift: ev.mods.shift && !every }) }) .flatten(); let outlines = widgets diff --git a/crates/tools/src/corners/tests.rs b/crates/tools/src/corners/tests.rs index a1fb0ee52..e555782d1 100644 --- a/crates/tools/src/corners/tests.rs +++ b/crates/tools/src/corners/tests.rs @@ -196,7 +196,9 @@ fn drag_follows_a_rotated_shape_and_a_click_does_nothing() { /// Direct Selection: a click on a widget (no drag) selects its corner, as a click on the /// corner's anchor does, so the next drag on that widget rounds that corner alone; Shift -/// toggles the corner. The Selection tool's click still does nothing. +/// toggles the corner, except while every corner is selected (#5188, see +/// `a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`). The Selection tool's click +/// still does nothing. #[test] fn a_click_on_a_widget_with_direct_selection_selects_its_corner() { let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0))); @@ -211,20 +213,80 @@ fn a_click_on_a_widget_with_direct_selection_selects_its_corner() { assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y)).is_empty()); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y)); assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "set"}))]); - let shift = Mods { shift: true, ..Mods::default() }; - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y).with_mods(shift)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y).with_mods(shift)); - assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "toggle"}))]); // With that corner selected, its widget alone shows, and a drag rounds it alone // (`direct_selected_corners_show_their_widgets_alone_and_round_alone`). let mut part = selected(id); part.anchors.insert(id, anchors.iter().copied().collect()); assert_eq!(CornerWidgets::of(&d, &part, 1.0, true).unwrap().corners(), [2]); + // A Shift-click on it then deselects it. + let shift = Mods { shift: true, ..Mods::default() }; + let one = crate::testutil::cx(&d, &part, &p); + t.pointer(&one, &PointerEvent::new(PointerKind::Down, w.x, w.y).with_mods(shift)); + let a = t.pointer(&one, &PointerEvent::new(PointerKind::Up, w.x, w.y).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "toggle"}))]); let mut t = crate::create("selection"); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y)); assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y)).is_empty()); } +/// #5188: while every corner of a path is selected (the path selected whole), a Shift-click on a +/// widget picks that corner alone, as the first click of "click one widget, then Shift-click the +/// others" does, rather than deselecting it and leaving the others. Once some corners are picked, +/// Shift-clicks toggle. +#[test] +fn a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner() { + let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0))); + let p = paint(); + let lc = LiveCorners::of(d.node(id).unwrap()).unwrap(); + let anchors = |k: usize| -> Vec<(usize, usize)> { lc.anchors_of(&[k].into()).into_iter().collect() }; + let shift = Mods { shift: true, ..Mods::default() }; + let click = |s: &Selection, at: Point| { + let cx = crate::testutil::cx(&d, s, &p); + let mut t = crate::create("directSelection"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y).with_mods(shift)) + }; + let pick = |k: usize, mode: &str| vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors(k), "mode": mode}))]; + // Selected whole: every widget shows; a Shift-click on one picks its corner alone. + let whole = selected(id); + let w = pts(&CornerWidgets::of(&d, &whole, 1.0, true).unwrap()); + assert_eq!(w.len(), 4); + assert_eq!(click(&whole, w[2]), pick(2, "set")); + // Every anchor direct-selected is every corner selected too. + let mut every = selected(id); + every.anchors.insert(id, (0..4).flat_map(anchors).collect()); + assert_eq!(click(&every, w[1]), pick(1, "set")); + // Two corners picked: a Shift-click on either widget deselects that corner. + let mut two = selected(id); + two.anchors.insert(id, [0, 2].into_iter().flat_map(anchors).collect()); + let shown = pts(&CornerWidgets::of(&d, &two, 1.0, true).unwrap()); + assert_eq!(shown, [w[0], w[2]]); + assert_eq!(click(&two, w[0]), pick(0, "toggle")); +} + +/// #5188: an anchor selected that is no corner (here half way along a side) shows every widget +/// but selects no corner, so a Shift-click on a widget adds its corner to that selection. +#[test] +fn a_shift_click_on_a_widget_adds_to_anchors_that_are_no_corner() { + let corners = [(100.0, 100.0), (150.0, 100.0), (200.0, 100.0), (200.0, 200.0), (100.0, 200.0)].map(|(x, y)| Point::new(x, y)); + let (d, id) = doc_with(PathData::single(vectorcraft_geom::SubPath::polyline(&corners, true)), None); + let p = paint(); + let mut s = selected(id); + s.anchors.insert(id, [(0, 1)].into()); + let cx = cx(&d, &s, &p); + let widgets = CornerWidgets::for_tool(&cx, true).unwrap(); + assert_eq!(widgets.corners().len(), 4, "no corner selected: every widget shows"); + let at = pts(&widgets)[0]; + let lc = LiveCorners::of(d.node(id).unwrap()).unwrap(); + let corner = *lc.all().iter().next().unwrap(); + let anchors: Vec<(usize, usize)> = lc.anchors_of(&[corner].into()).into_iter().collect(); + let shift = Mods { shift: true, ..Mods::default() }; + let mut t = crate::create("directSelection"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "toggle"}))]); +} + #[test] fn direct_selected_corners_show_their_widgets_alone_and_round_alone() { let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0))); diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index d27a9f2a4..a2ffbbf87 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -41,6 +41,11 @@ //! Dragging the brackets of selected type on a path moves or flips it ([`crate::pathtype`]). //! A press on a selected object's centre point (Attributes panel › Show Center) selects the object //! whole, and a drag from it moves the object. +//! A click on an instance of a dynamic symbol selects the object of the symbol's art under the +//! pointer (`symbol.instance.selectPart`), outlined: the solid color Fill, Stroke, Color, Swatches +//! and Eyedropper changes then go to that object for this instance only, and a drag moves nothing. +//! The engine refuses any object but a path, compound path or type. Alt and Shift pick the instance +//! whole, as for other objects. //! //! A press on the stroke of a path that isn't selected as a whole selects that segment's two anchors //! (the fill, or Alt, selects the whole path); dragging the segment bends it if it's curved, else @@ -50,8 +55,8 @@ use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; use serde_json::{Value, json}; -use vectorcraft_doc::hit::{Hit, HitKind, compound_part_at, hit_test, selected_hidden_at}; -use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection}; +use vectorcraft_doc::hit::{Hit, HitKind, compound_part_at, hit_test, selected_hidden_at, symbol_part_at, symbol_part_outline}; +use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection, symbol_overrides}; use vectorcraft_geom::{Anchor, PathData, PathSeg, Point, Rect, Vec2}; use crate::bbox::move_delta; @@ -667,6 +672,12 @@ fn next_group(cx: &ToolContext, ancestry: &[NodeId]) -> Option { .find(|id| !cx.selection.contains(*id)) } +/// Is an object of instance `id`'s art selected ([`vectorcraft_doc::Selection::symbol_part`]), +/// rather than the instance whole? +fn part_picked(cx: &ToolContext, id: NodeId) -> bool { + cx.selection.symbol_part.as_ref().is_some_and(|sp| sp.instance == id) +} + /// The segment of path `id` nearest to `p`, where the hit test found its stroke. fn segment_at(cx: &ToolContext, id: NodeId, p: Point) -> Option { let pd = cx.doc.node(id)?.path_data()?; @@ -984,6 +995,11 @@ impl Tool for DirectSelectionTool { let mut out = self.spine_overlays(cx); out.extend(self.mesh.overlays(cx, &|id| cx.selection.contains(id))); if !self.group { + if let Some(sp) = &cx.selection.symbol_part + && let Some(path) = cx.doc.node(sp.instance).and_then(|n| symbol_part_outline(cx.doc, n, &sp.path)) + { + out.push(Overlay::Path { path, color: cx.doc.layer_color(sp.instance), width: 1.0, dashed: false }); + } out.extend(frame_overlays(cx, &self.frame_anchors(cx))); let drag = if let State::Bracket(b) = &self.state { Some(b) } else { None }; out.extend(pathtype::overlays(cx, self.bracket_hover, drag)); @@ -1309,7 +1325,7 @@ impl DirectSelectionTool { if ev.mods.shift { return vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))]; } - if cx.selection.partial(id).is_some() || cx.selection.segments.contains_key(&id) { + if cx.selection.partial(id).is_some() || cx.selection.segments.contains_key(&id) || part_picked(cx, id) { return vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; } vec![] @@ -1319,6 +1335,26 @@ impl DirectSelectionTool { h.ancestry.push(part); h.leaf = part; } + // On an instance of a dynamic symbol a click picks the object of its art under the pointer; + // a drag from it moves nothing (`object.transform` refuses while it is selected). + if !ev.mods.alt + && !ev.mods.shift + && let Some(n) = cx.doc.node(h.leaf) + && let NodeKind::SymbolInstance { symbol, .. } = &n.kind + && let Some(sym) = cx.doc.symbols.iter().find(|s| s.name == *symbol && s.dynamic) + && let Some(path) = symbol_part_at(cx.doc, n, p, cx.selection_hit_options()) + { + self.frame.clear(); + // The engine refuses any other object with a status message, and the selection stays, + // so a drag from the press does not move it. + if symbol_overrides::part(&sym.art, &path).is_some_and(symbol_overrides::takes_part_paint) { + self.state = State::MoveObject { start: p, grab: None, hold: hold_point(cx, &h, p), began: false, deselect: None }; + } + if cx.selection.symbol_part.as_ref().is_some_and(|sp| sp.instance == h.leaf && sp.path == path) { + return vec![]; + } + return vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": h.leaf.0, "path": path}))]; + } // On a gradient mesh a click picks the point, the mesh line's two ends or the four points // around the patch under it (Shift toggles them), and selects the mesh; a drag moves the // picked points. @@ -1340,7 +1376,7 @@ impl DirectSelectionTool { // A segment pick keeps its endpoints available for movement without selecting // the anchors themselves. Dragging reshapes a curve, else moves it. Alt picks // the whole path (and Alt-drag copies it), as with Group Selection. - let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none(); + let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none() && !part_picked(cx, h.leaf); if !ev.mods.alt && matches!(h.kind, HitKind::Stroke | HitKind::Outline) && let Some(s) = segment_at(cx, h.leaf, p) @@ -1376,7 +1412,7 @@ impl DirectSelectionTool { return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))]; } self.frame.clear(); - if !cx.selection.contains(h.leaf) || cx.selection.partial(h.leaf).is_some() { + if !cx.selection.contains(h.leaf) || cx.selection.partial(h.leaf).is_some() || part_picked(cx, h.leaf) { return vec![Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]}))]; } vec![] diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index a8507e693..7948b4707 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -128,6 +128,31 @@ fn a_press_on_a_clipping_path_takes_its_segment_over_the_art() { assert!(names(&a, art) && !names(&a, clip), "{a:?}"); } +/// #3748: in isolation mode Direct Selection and Group Selection take the art that the +/// isolated clip group hides outside its mask, and a drag from there moves it; outside +/// isolation mode a press there does not take it. +#[test] +fn isolation_lets_direct_selection_take_the_art_a_clip_group_hides() { + let (d, g, _, art) = doc_with_masked_square(); + let (s, p) = (Selection::default(), paint()); + let mut picked = Selection::default(); + picked.add(art); + let names = |actions: &[Action]| actions.iter().any(|a| matches!(a, Action::Exec(_, v) if v["id"] == json!(art.0) || v["ids"] == json!([art.0]))); + for group in [false, true] { + let isolated = ToolContext { isolation: Some(g), ..cx(&d, &s, &p) }; + let mut t = DirectSelectionTool::new(group); + let a = t.pointer(&isolated, &PointerEvent::new(PointerKind::Down, 530.0, 450.0)); + assert!(names(&a), "group {group}: {a:?}"); + // The press selected the art: the drag that follows moves it. + let isolated = ToolContext { isolation: Some(g), ..cx(&d, &picked, &p) }; + let a = t.pointer(&isolated, &PointerEvent::new(PointerKind::Drag, 510.0, 470.0)); + assert!(a.iter().any(|a| matches!(a, Action::Begin(n) if n == "Move")), "group {group}: {a:?}"); + assert!(a.iter().any(|a| matches!(a, Action::Preview(..))), "group {group}: {a:?}"); + let a = DirectSelectionTool::new(group).pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 530.0, 450.0)); + assert!(!names(&a), "group {group}, not isolated: {a:?}"); + } +} + /// A blend of two 20 pt squares centred on (110, 310) and (210, 310), selected. fn blend_doc() -> (vectorcraft_doc::Document, NodeId, NodeId) { let (mut d, _) = doc_with_rect(); @@ -2147,3 +2172,165 @@ fn selected_path_handle_over_background_rect_is_draggable() { let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); assert_eq!(acts, vec![Action::Begin("Reshape".into())]); } + +/// On an instance of a dynamic symbol, a Direct Selection click picks the object of the symbol's +/// art under the pointer, which is then outlined; Alt and Shift, a static symbol and Group +/// Selection pick the instance whole, as before. +#[test] +fn a_click_on_a_dynamic_instance_picks_the_object_under_the_pointer() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, Symbol}; + let square = |id, x: f64| { + Arc::new(Node::path(NodeId(id), vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default_art())) + }; + let art = Node::group(NodeId(0), vec![square(0, 0.0), square(0, 20.0)]); + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + d.symbols.push(Symbol { dynamic: true, name: "Pair".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let xf = vectorcraft_geom::Affine::translate((300.0, 300.0)) * vectorcraft_geom::Affine::scale(2.0); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Pair".into(), xf })).unwrap(); + let (none, p) = (Selection::default(), paint()); + let down = |d: &vectorcraft_doc::Document, sel: &Selection, group: bool, x: f64, y: f64, m: Mods| { + DirectSelectionTool::new(group).pointer(&cx(d, sel, &p), &PointerEvent::new(PointerKind::Down, x, y).with_mods(m)) + }; + let part = |path: Value| vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": id.0, "path": path}))]; + // The second square is drawn at 340..360. + assert_eq!(down(&d, &none, false, 350.0, 310.0, Mods::default()), part(json!([1]))); + assert_eq!(down(&d, &none, false, 310.0, 310.0, Mods::default()), part(json!([0]))); + let whole = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; + assert_eq!(down(&d, &none, false, 350.0, 310.0, Mods { alt: true, ..Mods::default() }), whole, "Alt picks the instance"); + let shift = down(&d, &none, false, 350.0, 310.0, Mods { shift: true, ..Mods::default() }); + assert_eq!(shift, vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))]); + assert_eq!(down(&d, &none, true, 350.0, 310.0, Mods::default()), whole, "Group Selection picks the instance"); + // The part selected: a click on it again keeps it, and it is outlined. + let mut sel = Selection::default(); + sel.set_symbol_part(id, vec![1]); + assert_eq!(down(&d, &sel, false, 350.0, 310.0, Mods::default()), vec![]); + let outline = DirectSelectionTool::new(false).overlays(&cx(&d, &sel, &p)); + let drawn = + outline.iter().find_map(|o| if let Overlay::Path { path, .. } = o { Some(vectorcraft_geom::kurbo::Shape::bounding_box(path)) } else { None }); + assert_eq!(drawn, Some(Rect::new(340.0, 300.0, 360.0, 320.0))); + // With the part selected, an Alt-click on the instance, or a press on its center point, picks + // the instance whole again. + assert_eq!(down(&d, &sel, false, 350.0, 310.0, Mods { alt: true, ..Mods::default() }), whole, "Alt with the part selected"); + d.node_mut(id).unwrap().edit_attrs(|a| a.show_center = Some(true)); + assert_eq!(down(&d, &sel, false, 330.0, 310.0, Mods::default()), whole, "the center point with the part selected"); + // A static symbol: the instance is picked whole. + d.symbols[0].dynamic = false; + assert_eq!(down(&d, &none, false, 350.0, 310.0, Mods::default()), whole); +} + +/// In a dynamic symbol's art, a Direct Selection press passes over a locked object: it picks the +/// unlocked object beneath it, and where only a locked object is under the pointer it picks the +/// instance whole. +#[test] +fn a_press_on_a_dynamic_instance_passes_over_locked_objects() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, Symbol}; + let square = |x: f64, locked: bool| { + let mut n = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default_art()); + n.locked = locked; + Arc::new(n) + }; + // The unlocked square at 0..10 has a locked square over it; the square at 20..30 is locked. + let art = Node::group(NodeId(0), vec![square(0.0, false), square(0.0, true), square(20.0, true)]); + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + d.symbols.push(Symbol { dynamic: true, name: "Locked".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let xf = vectorcraft_geom::Affine::translate((300.0, 300.0)); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Locked".into(), xf })).unwrap(); + let (none, p) = (Selection::default(), paint()); + let down = |x: f64, y: f64| DirectSelectionTool::new(false).pointer(&cx(&d, &none, &p), &PointerEvent::new(PointerKind::Down, x, y)); + assert_eq!(down(305.0, 305.0), vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": id.0, "path": [0]}))]); + assert_eq!(down(325.0, 305.0), vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); + // A locked clipping path still clips, but a press on its outline picks the art it clips. + let mut clip = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art()); + clip.locked = true; + let art = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(-10.0, -10.0, 30.0, 30.0)), Appearance::default_art()); + let masked = Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }); + let mut d = d.clone(); + d.symbols[0].art = Arc::new(masked); + let down = |x: f64, y: f64| DirectSelectionTool::new(false).pointer(&cx(&d, &none, &p), &PointerEvent::new(PointerKind::Down, x, y)); + assert_eq!(down(319.0, 310.0), vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": id.0, "path": [1]}))]); +} + +/// In a dynamic symbol's art, a Direct Selection press passes over a guide path while guides are +/// hidden or locked and picks the square beneath it; with guides shown and unlocked it picks the +/// guide path. The same goes for a press on the edge of a guide used as a clipping path. +#[test] +fn a_press_on_a_dynamic_instance_passes_over_guides_while_they_cannot_be_picked() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, Symbol}; + let square = |r: Rect, is_guide: bool| { + let mut n = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(r), Appearance::default_art()); + let NodeKind::Path { guide, .. } = &mut n.kind else { panic!("Node::path makes a path") }; + *guide = is_guide; + Arc::new(n) + }; + let small = Rect::new(0.0, 0.0, 10.0, 10.0); + let art = Node::group(NodeId(0), vec![square(small, false), square(small, true)]); + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + d.symbols.push(Symbol { dynamic: true, name: "Guided".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let xf = vectorcraft_geom::Affine::translate((300.0, 300.0)); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Guided".into(), xf })).unwrap(); + let (none, p) = (Selection::default(), paint()); + let part = |path: Value| vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": id.0, "path": path}))]; + let down = |d: &vectorcraft_doc::Document, guides: bool, x: f64, y: f64| { + let c = ToolContext { guides, ..cx(d, &none, &p) }; + DirectSelectionTool::new(false).pointer(&c, &PointerEvent::new(PointerKind::Down, x, y)) + }; + assert_eq!(down(&d, false, 305.0, 305.0), part(json!([0])), "guides off: the square beneath"); + assert_eq!(down(&d, true, 305.0, 305.0), part(json!([1])), "guides on: the guide"); + // A guide gives a clip group no clip region, so nothing in the group is hit away from its edge, + // as outside symbols. With guides off a press on its edge picks the instance whole. + let clip = Rect::new(0.0, 0.0, 20.0, 20.0); + let masked = + Node::new(NodeId(0), NodeKind::Group { children: vec![square(clip, true), square(Rect::new(-10.0, -10.0, 30.0, 30.0), false)], clip: true }); + d.symbols[0].art = Arc::new(masked); + assert_eq!( + down(&d, false, 319.0, 310.0), + vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))], + "guides off: the clipping path's edge" + ); + assert_eq!(down(&d, true, 319.0, 310.0), part(json!([0])), "guides on: the clipping path's edge"); +} + +/// In a dynamic symbol's art, a Direct Selection press on the outline of a clip group's clipping +/// path picks the clipping path, also where the art it clips covers the path's edge; inside the +/// mask away from the edge the art is picked. +#[test] +fn a_press_on_a_clipping_path_in_a_dynamic_instance_picks_it() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, Symbol}; + let rect = |r: Rect| Arc::new(Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())); + let art = Node::new( + NodeId(0), + NodeKind::Group { children: vec![rect(Rect::new(0.0, 0.0, 20.0, 20.0)), rect(Rect::new(-10.0, -10.0, 30.0, 30.0))], clip: true }, + ); + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + d.symbols.push(Symbol { dynamic: true, name: "Masked".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let xf = vectorcraft_geom::Affine::translate((300.0, 300.0)); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Masked".into(), xf })).unwrap(); + let (none, p) = (Selection::default(), paint()); + let down = |x: f64, y: f64| DirectSelectionTool::new(false).pointer(&cx(&d, &none, &p), &PointerEvent::new(PointerKind::Down, x, y)); + let part = |path: Value| vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": id.0, "path": path}))]; + // The clipping path's right edge is at x = 320; the art covers it. + assert_eq!(down(321.0, 310.0), part(json!([0])), "just outside the mask, on its edge"); + assert_eq!(down(319.0, 310.0), part(json!([0])), "just inside the mask, on its edge"); + assert_eq!(down(310.0, 310.0), part(json!([1])), "inside the mask, the art"); + // Clipped art from 10 to 30 picked: its outline is cut to the clipping path's bounds (0 to 20), + // where it is picked. + let art = Node::new( + NodeId(0), + NodeKind::Group { children: vec![rect(Rect::new(0.0, 0.0, 20.0, 20.0)), rect(Rect::new(10.0, 10.0, 30.0, 30.0))], clip: true }, + ); + d.symbols[0].art = Arc::new(art); + let mut sel = Selection::default(); + sel.set_symbol_part(id, vec![1]); + let outline = DirectSelectionTool::new(false).overlays(&cx(&d, &sel, &p)); + let drawn = + outline.iter().find_map(|o| if let Overlay::Path { path, .. } = o { Some(vectorcraft_geom::kurbo::Shape::bounding_box(path)) } else { None }); + assert_eq!(drawn, Some(Rect::new(310.0, 310.0, 320.0, 320.0))); +} diff --git a/crates/tools/src/draw2/family.rs b/crates/tools/src/draw2/family.rs index 0bbdcf79b..8058101b1 100644 --- a/crates/tools/src/draw2/family.rs +++ b/crates/tools/src/draw2/family.rs @@ -12,6 +12,7 @@ //! On a grid, F and V lower and raise //! the skew of the horizontal (rectangular) or radial (polar) dividers, and X and C lower and raise //! that of the vertical or concentric ones. +//! Escape cancels the drag and puts back the options its keys and modifiers changed. use serde_json::{Value, json}; use vectorcraft_geom::Point; @@ -63,6 +64,22 @@ pub struct FamilyTool { /// its own). The Reference Point is `reference`. pub grid_size: (f64, f64), snap: DrawSnap, + /// The options a drag's keys and modifiers change, as they were when the drag began: Escape + /// puts them back. + before_drag: Option, +} + +/// The options that keys and modifiers change while a shape is dragged out. +#[derive(Clone, Copy)] +struct DragOptions { + slope: f64, + closed: bool, + base_along: &'static str, + decay: f64, + segments: u32, + rows: u32, + columns: u32, + skew: [f64; 2], } impl FamilyTool { @@ -99,9 +116,35 @@ impl FamilyTool { fill: false, grid_size: (100.0, 100.0), snap: DrawSnap::default(), + before_drag: None, } } + fn drag_options(&self) -> DragOptions { + DragOptions { + slope: self.slope, + closed: self.closed, + base_along: self.base_along, + decay: self.decay, + segments: self.segments, + rows: self.rows, + columns: self.columns, + skew: self.skew, + } + } + + /// Put back the options a canceled drag changed. + fn restore_drag_options(&mut self, o: DragOptions) { + self.slope = o.slope; + self.closed = o.closed; + self.base_along = o.base_along; + self.decay = o.decay; + self.segments = o.segments; + self.rows = o.rows; + self.columns = o.columns; + self.skew = o.skew; + } + fn label(&self) -> &'static str { match self.id { "spiral" => "Spiral", @@ -127,7 +170,7 @@ impl FamilyTool { /// with the tool's decay and segments (a logarithm, so that a low decay with many segments does /// not round to 0). The decay ratio is clamped to 0.05…0.9999 as `shapes::spiral` clamps it. fn spiral_inner_end_ln(&self, r: f64) -> f64 { - r.ln() + f64::from(self.segments) * (self.decay / 100.0).clamp(0.05, 0.9999).ln() + r.ln() + f64::from(self.segments) / 4.0 * (self.decay / 100.0).clamp(0.05, 0.9999).ln() } /// Alt or Ctrl/Cmd in a spiral drag from `start` to `p`: the inner end keeps the distance from @@ -150,12 +193,12 @@ impl FamilyTool { } let Some(end) = self.spiral_end.filter(|e| e.is_finite()) else { return }; if m.cmd { - let ratio = ((end - r.ln()) / f64::from(self.segments)).exp(); + let ratio = ((end - r.ln()) * 4.0 / f64::from(self.segments)).exp(); if ratio.is_finite() { self.decay = (ratio * 100.0).clamp(5.0, 99.99); } } else { - let fitted = (r.ln() - end) / -(self.decay / 100.0).clamp(0.05, 0.9999).ln(); + let fitted = (r.ln() - end) * 4.0 / -(self.decay / 100.0).clamp(0.05, 0.9999).ln(); if fitted.is_finite() { let base = f64::from(self.spiral_alt_base.unwrap_or(self.segments)); self.segments = (base + 4.0 * ((fitted - base) / 4.0).round()).clamp(2.0, 1000.0) as u32; @@ -264,6 +307,7 @@ impl Tool for FamilyTool { self.began = false; self.spiral_end = None; self.spiral_alt_base = None; + self.before_drag = None; vec![] } PointerKind::Drag => { @@ -279,6 +323,7 @@ impl Tool for FamilyTool { return out; } self.began = true; + self.before_drag = Some(self.drag_options()); out.push(Action::Begin(self.label().into())); } if self.id == "spiral" { @@ -293,6 +338,7 @@ impl Tool for FamilyTool { self.snap.clear(); self.spiral_end = None; self.spiral_alt_base = None; + self.before_drag = None; if std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![Action::Dialog(self.id.into(), json!({"x": s.x, "y": s.y}))] } } _ => vec![], @@ -364,6 +410,9 @@ impl Tool for FamilyTool { self.began = false; self.spiral_end = None; self.spiral_alt_base = None; + if let Some(o) = self.before_drag.take() { + self.restore_drag_options(o); + } self.snap.clear(); vec![Action::Cancel] } else { @@ -442,6 +491,23 @@ impl Tool for FamilyTool { "height" => self.grid_size.1 = grid_length(v, self.grid_size.1), _ => {} } + // An option set while a shape is dragged out stays when Escape cancels the drag; a value + // the match above ignores leaves the drag's starting value as it was. + if let Some(b) = self.before_drag.as_mut() { + let finite = v.as_f64().is_some_and(f64::is_finite); + match key { + "slope" if finite => b.slope = self.slope, + "closed" if v.is_boolean() => b.closed = self.closed, + "baseAlong" if v.is_string() => b.base_along = self.base_along, + "decay" if v.as_f64().is_some() => b.decay = self.decay, + "segments" => b.segments = self.segments, + "rows" | "concentric" => b.rows = self.rows, + "columns" | "radial" => b.columns = self.columns, + "rowSkew" | "concentricSkew" if finite => b.skew[0] = self.skew[0], + "columnSkew" | "radialSkew" if finite => b.skew[1] = self.skew[1], + _ => {} + } + } } } @@ -631,48 +697,49 @@ mod tests { assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0))), (100.0, 80.0, 10)); // Alt rounds the fitted count to whole winds from the count when Alt goes down. assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(alt))), (100.0, 80.0, 10)); - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt))), (200.0, 80.0, 14)); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt))), (200.0, 80.0, 22)); // ↑ with Alt held adds one, and the held inner end moves with it. - assert_eq!(spiral(t.key(&cx, ToolKey::Up, alt)).2, 15); - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt))).2, 15); - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(alt))), (50.0, 80.0, 7)); + assert_eq!(spiral(t.key(&cx, ToolKey::Up, alt)).2, 23); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt))).2, 23); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(alt))), (50.0, 80.0, 2)); assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 101.0, 100.0).with_mods(alt))).2, 2, "at least two"); - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(alt))).2, 7); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(alt))).2, 2); // The count stays as the radius follows the pointer again. - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0))), (100.0, 80.0, 7)); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0))), (100.0, 80.0, 2)); // Ctrl/Cmd fits the decay to the held inner end. t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(ctrl)); let (r, decay, n) = spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(ctrl))); - assert!((r, n) == (50.0, 7) && (decay - 80.0 * 2f64.powf(1.0 / 7.0)).abs() < 1e-9, "{r} {decay} {n}"); + assert_eq!((r, decay, n), (50.0, 99.99, 2), "{r} {decay} {n}"); let (_, decay, _) = spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 400.0, 100.0).with_mods(ctrl))); - assert!((decay - 80.0 * (1.0f64 / 3.0).powf(1.0 / 7.0)).abs() < 1e-9, "{decay}"); + assert!((decay - 80.0 / 9.0).abs() < 1e-9, "{decay}"); assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.5, 100.0).with_mods(ctrl))).1, 99.99, "at most 99.99 %"); assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 1e20, 100.0).with_mods(ctrl))).1, 5.0, "at least 5 %"); let (_, decay, _) = spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(ctrl))); - assert!((decay - 80.0 * 0.5f64.powf(1.0 / 7.0)).abs() < 1e-9, "{decay}"); + assert!((decay - 20.0).abs() < 1e-9, "{decay}"); // Let go: the decay stays, and the spiral drawn next keeps both. - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 100.0))), (150.0, decay, 7)); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 100.0))), (150.0, decay, 2)); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 100.0)), vec![Action::Commit]); - assert_eq!((t.options()["decay"].as_f64(), t.options()["segments"].clone()), (Some(decay), json!(7))); + assert_eq!((t.options()["decay"].as_f64(), t.options()["segments"].clone()), (Some(decay), json!(2))); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); let (_, d2, n2) = spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 140.0).with_mods(alt))); - assert_eq!((d2, n2), (decay, 7), "a new drag holds the end anew"); - // 5 % and 300 segments: an inner radius of 100 × 0.05^300 is below the smallest f64. + assert_eq!((d2, n2), (decay, 2), "a new drag holds the end anew"); + // Low decay over many winds still fits using logarithms. let mut t = FamilyTool::new("spiral"); t.set_option("decay", &json!(5.0)); t.set_option("segments", &json!(300)); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(ctrl)); let (_, decay, _) = spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(ctrl))); - assert!((decay - 5.0 * 2f64.powf(1.0 / 300.0)).abs() < 1e-9, "{decay}"); + assert!((decay - 5.0 * 2f64.powf(4.0 / 300.0)).abs() < 1e-9, "{decay}"); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0)); // Alt: a large radius increase adds a whole wind. t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0).with_mods(alt)); - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200100.0, 100.0).with_mods(alt))).2, 304); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200100.0, 100.0).with_mods(alt))).2, 312); } /// Switching between Alt and Ctrl/Cmd keeps the held inner end and uses the current segment - /// count when Alt resumes. Escape clears the held end before the next drag. + /// count when Alt resumes. Escape clears the held end and puts back the segments and decay + /// from before the drag. #[test] fn spiral_drag_switches_between_alt_and_ctrl_and_escape_lets_go_of_the_inner_end() { let (d, _) = doc_with_rect(); @@ -687,21 +754,137 @@ mod tests { t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(alt)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt)); - assert_eq!(t.segments, 14); + assert_eq!(t.segments, 22); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(both)); - assert_eq!(t.segments, 14, "Ctrl/Cmd takes precedence"); + assert_eq!(t.segments, 22, "Ctrl/Cmd takes precedence"); let decay = t.decay; assert!(decay > 80.0); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(ctrl)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt)); - assert_eq!(t.segments, 18, "Alt resumes from the current count with the held end"); + assert_eq!(t.segments, 50, "Alt resumes from the current count with the held end"); assert_eq!(t.decay, decay); t.key(&cx, ToolKey::Escape, alt); assert_eq!(t.spiral_end, None); assert_eq!(t.spiral_alt_base, None); + assert_eq!((t.segments, t.decay), (10, 80.0), "the options from before the drag"); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(alt)); - assert_eq!(t.segments, 18, "a new drag holds a new inner end"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt)); + assert_eq!(t.segments, 22, "a new drag holds a new inner end"); + } + + /// Escape during a drag puts back every option the drag's keys 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; + let keys = |id: &str| -> Vec { + match id { + "arc" => vec![ToolKey::Up, ToolKey::Letter('c'), ToolKey::Letter('f')], + "spiral" => vec![ToolKey::Up], + _ => vec![ToolKey::Up, ToolKey::Right, ToolKey::Letter('v'), ToolKey::Letter('c')], + } + }; + let arc_and_spiral = ["slope", "closed", "baseAlong", "segments", "decay"]; + let grids = ["rows", "columns", "concentric", "radial", "rowSkew", "columnSkew", "concentricSkew", "radialSkew"]; + let pick = |v: &Value| arc_and_spiral.iter().chain(&grids).filter_map(|f| v.get(*f).map(|x| (*f, x.clone()))).collect::>(); + for id in ["arc", "spiral", "rectangularGrid", "polarGrid"] { + let mut t = FamilyTool::new(id); + let preview = |a: Vec| match a.last() { + Some(Action::Preview(_, v)) => v.clone(), + _ => panic!("{id}: {a:?}"), + }; + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + let before = pick(&preview(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)))); + let options = t.options(); + let mut changed = before.clone(); + for k in keys(id) { + changed = pick(&preview(t.key(&cx, k, Mods::default()))); + } + assert_ne!(changed, before, "{id}: the keys change the options"); + 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)); + let next = pick(&preview(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)))); + assert_eq!(next, before, "{id}: the next drag"); + // A drag that is let go keeps what its keys changed. + for k in keys(id) { + t.key(&cx, k, 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)); + let kept = pick(&preview(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)))); + assert_eq!(kept, changed, "{id}: a finished drag keeps them"); + t.key(&cx, ToolKey::Escape, Mods::default()); + assert_eq!(pick(&t.options()), changed, "{id}: Escape puts back only what this drag changed"); + } + } + + /// Alt and Ctrl/Cmd change a spiral's segments and decay as it is dragged out; Escape puts + /// both back. + #[test] + fn escape_puts_back_the_spiral_decay_and_segments_alt_and_ctrl_changed() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let alt = Mods { alt: true, ..Mods::default() }; + let ctrl = Mods { cmd: true, ..Mods::default() }; + let mut t = FamilyTool::new("spiral"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(alt)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(ctrl)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(ctrl)); + assert!(t.segments != 10 && t.decay != 80.0, "{} {}", t.segments, t.decay); + t.key(&cx, ToolKey::Escape, ctrl); + assert_eq!((t.segments, t.decay), (10, 80.0)); + } + + /// An option set while a shape is dragged out (`tool.setOption`) stays when Escape cancels + /// the drag; the options only the drag's keys changed go back. + #[test] + fn escape_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 mut t = FamilyTool::new("rectangularGrid"); + 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.key(&cx, ToolKey::Right, Mods::default()); + t.set_option("columns", &json!(9)); + t.set_option("rowSkew", &json!(30.0)); + t.key(&cx, ToolKey::Escape, Mods::default()); + let o = t.options(); + assert_eq!((o["rows"].clone(), o["columns"].clone(), o["rowSkew"].clone()), (json!(5), json!(9), json!(30.0))); + let mut t = FamilyTool::new("arc"); + 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::Letter('c'), Mods::default()); + t.set_option("slope", &json!(-20.0)); + t.key(&cx, ToolKey::Escape, Mods::default()); + let o = t.options(); + assert_eq!((o["closed"].clone(), o["slope"].clone()), (json!(false), json!(-20.0))); + // A value the option ignores leaves the starting value for Escape. + 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.key(&cx, ToolKey::Letter('c'), Mods::default()); + t.key(&cx, ToolKey::Letter('f'), Mods::default()); + for (k, v) in [("slope", json!("steep")), ("closed", json!(1)), ("baseAlong", json!(null))] { + t.set_option(k, &v); + } + t.key(&cx, ToolKey::Escape, Mods::default()); + let o = t.options(); + assert_eq!((o["slope"].clone(), o["closed"].clone(), o["baseAlong"].clone()), (json!(-20.0), json!(false), json!("x"))); } /// Alt adjusts the segment count in whole winds while keeping it within 2 to 1000. @@ -712,7 +895,7 @@ mod tests { let start = Point::new(0.0, 0.0); t.hold_spiral_end(start, Point::new(100.0, 0.0), alt); t.hold_spiral_end(start, Point::new(200.0, 0.0), alt); - assert_eq!(t.segments, 14); + assert_eq!(t.segments, 22); t.hold_spiral_end(start, Point::new(0.01, 0.0), alt); assert_eq!(t.segments, 2); t.hold_spiral_end(start, Point::new(1e150, 0.0), alt); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index f77ba9dbf..557fe52b1 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -57,7 +57,7 @@ mod context; mod cursor; mod input; -pub use context::{GlyphSnap, PaintDefaults, ScreenFrame, ToolContext}; +pub use context::{GlyphSnap, MarqueeMode, PaintDefaults, ScreenFrame, ToolContext}; pub use cursor::Cursor; pub use input::{Action, Mods, PenPose, PointerEvent, PointerKind, ToolKey}; @@ -66,6 +66,8 @@ pub use input::{Action, Mods, PenPose, PointerEvent, PointerKind, ToolKey}; pub enum Overlay { /// Marquee / rubber band rectangle. Marquee(Rect), + /// Selection-tool marquee, with its effective rule and pointer position for feedback. + SelectionMarquee { rect: Rect, enclosed: bool, pointer: Point }, /// A path preview (e.g. pen rubber band) in a colour (RGB). Path { path: BezPath, color: [u8; 3], width: f32, dashed: bool }, /// A line segment (smart guide, handle line). @@ -146,10 +148,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 { @@ -237,6 +244,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. @@ -256,21 +267,41 @@ pub trait Tool: Send { false } /// Is the tool waiting for work it runs on another thread (the planar map of a large selection - /// for the Shape Builder and the Live Paint Bucket)? It highlights no region under the pointer - /// until the work is done. While this holds, the desktop app calls [`Tool::poll`] each frame, - /// and hosts without frames call it with `wait` after a [`PointerKind::Move`]. + /// for the Shape Builder and the Live Paint Bucket, a Shape Builder merge of one)? It + /// highlights nothing under the pointer until a map is done. While this holds, the desktop app + /// calls [`Tool::poll`] each frame, and hosts without frames call it with `wait` after each + /// pointer event. fn waiting(&self) -> bool { false } - /// Checks on the work [`Tool::waiting`] reports, waiting for it to finish with `wait`. Once it - /// is done, the tool shows the highlight under the pointer where it last moved. - fn poll(&mut self, _cx: &ToolContext, _wait: bool) {} + /// Is the tool computing an edit on another thread, which [`Tool::poll`] applies once it is + /// done (a Shape Builder merge of a large selection)? + fn editing(&self) -> bool { + false + } + /// Checks on the work [`Tool::waiting`] reports, waiting for it to finish with `wait`. Once a + /// map is done, the tool shows the highlight under the pointer where it last moved; once an + /// edit is done, the actions returned apply it. + fn poll(&mut self, _cx: &ToolContext, _wait: bool) -> Vec { + vec![] + } + /// Waits for the edit [`Tool::editing`] reports and returns the actions that apply it, before + /// a command runs on the document; none when the document's revision or the selection changed + /// since the gesture. + fn finish_edit(&mut self, _cx: &ToolContext) -> Vec { + vec![] + } /// The Shape Builder's planar map of the selection, with the outline edges, and the gap it /// closes, when the tool has one. `shapeBuilder.merge` uses it in place of a map of its own /// when the map's inputs are the merge's inputs. fn shape_builder_map(&self) -> Option<(std::sync::Arc, f64)> { None } + /// The Shape Builder merge the tool computed on another thread for its last gesture, which + /// `shapeBuilder.merge` takes when it would compute the same ([`builder::PreparedMerge`]). + fn shape_builder_merge(&self) -> Option> { + None + } } /// Create a tool by id. Unknown or not-yet-implemented tools fall back to a no-op tool that keeps the id. @@ -482,6 +513,7 @@ pub(crate) mod testutil { revision: (0, 0), selection: s, zoom: 1.0, + marquee_mode: MarqueeMode::Touching, isolation: None, paint: p, outline: false, @@ -508,6 +540,7 @@ pub(crate) mod testutil { unit: Unit::Points, stroke_unit: Unit::Points, paste_plain_text: false, + typed_from_keyboard: false, slices_hidden: false, slices_locked: false, auto_add_delete: true, @@ -525,6 +558,7 @@ pub(crate) mod testutil { pen_rubber_band: true, curvature_rubber_band: true, placeholder_text: false, + typed_lists: true, smart_guide_color: guides::MAGENTA, alignment_guides: true, anchor_path_labels: true, diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index eaa13cf42..5edc14ad4 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -180,6 +180,11 @@ impl Tool for BlendTool { fn chosen_again(&mut self, cx: &ToolContext) -> Vec { self.deactivate(cx) } + /// 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)] diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 6d90e79e3..e39837c7c 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -16,8 +16,8 @@ //! The bounding box stands at the selection's own angle after a rotation, so its handles scale //! along the objects' axes. A handle drag resizes area type's frame (the type area) instead of //! scaling its type: the text reflows at its size. -//! E pressed during a marquee drag makes the marquee take only the objects entirely inside it, and -//! E pressed again makes it take the objects it touches. +//! Marquee Selection defaults to touched objects, with fully enclosed and screen drag-direction +//! alternatives. E reverses the rule for the current drag. The overlay reports the effective rule. //! Escape during a drag (a move or an Alt-drag copy, a scale, a rotation, a marquee, a widget or //! bracket drag) cancels it: the art returns to its state at the press. //! Selected area type and type on a path show an in port and an out port. A click on the out port @@ -55,6 +55,29 @@ use crate::shaperwidget; use crate::typewidget; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids}; +/// Direction is read on screen, even in a turned view. Within three pixels of the press's +/// horizontal position, retain the previous rule so hand jitter does not flip it. +fn marquee_direction(cx: &ToolContext, start: Point, cur: Point, previous: bool) -> bool { + let dx = cx.screen.and_then(|f| Some(f.to_screen(cur)?.0 - f.to_screen(start)?.0)).unwrap_or((cur.x - start.x) * cx.zoom); + if dx > 3.0 { + true + } else if dx < -3.0 { + false + } else { + previous + } +} + +/// The same effective rule drives artwork, ruler guides and the visible feedback. +fn marquee_enclosed(cx: &ToolContext, inverted: bool, directional_inside: bool) -> bool { + let inside = match cx.marquee_mode { + crate::MarqueeMode::Touching => false, + crate::MarqueeMode::Enclosed => true, + crate::MarqueeMode::Directional => directional_inside, + }; + inside != inverted +} + #[derive(Clone, Debug, Default)] enum State { #[default] @@ -97,9 +120,10 @@ enum State { cur: Point, /// Shift: the objects inside leave the selection if selected, else join it. toggle: bool, - /// E pressed an odd number of times during the drag: the marquee takes only the objects - /// that lie entirely inside it. + /// E pressed an odd number of times during the drag: reverse the chosen rule. enclosed: bool, + /// Last horizontal direction outside the 3 screen-pixel dead zone at the press. + directional_inside: bool, }, /// Dragging a Live Corners widget. Corner(CornerDrag), @@ -798,6 +822,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, @@ -952,8 +980,9 @@ impl Tool for SelectionTool { } vec![Action::Preview("object.transform".into(), params)] } - (PointerKind::Drag, State::Marquee { start, toggle, enclosed, .. }) => { - self.state = State::Marquee { start, cur: p, toggle, enclosed }; + (PointerKind::Drag, State::Marquee { start, toggle, enclosed, directional_inside, .. }) => { + self.state = + State::Marquee { start, cur: p, toggle, enclosed, directional_inside: marquee_direction(cx, start, p, directional_inside) }; vec![] } (PointerKind::Drag, State::Corner(mut c)) => { @@ -1019,13 +1048,14 @@ impl Tool for SelectionTool { out.push(Action::Commit); out } - (PointerKind::Up, State::Marquee { start, toggle, enclosed, .. }) => { + (PointerKind::Up, State::Marquee { start, toggle, enclosed, directional_inside, .. }) => { self.state = State::Idle; let r = Rect::from_points(start, p); if r.width() < Self::drag_threshold(cx) && r.height() < Self::drag_threshold(cx) { return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] }; } let touched = marquee(cx.doc, r, cx.isolation, false); + let enclosed = marquee_enclosed(cx, enclosed, marquee_direction(cx, start, p, directional_inside)); let ids = if enclosed { inside(cx, r, touched) } else { touched }; let art = Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": json_ids(&ids) })); std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, enclosed)).collect() @@ -1046,7 +1076,11 @@ impl Tool for SelectionTool { } } match &self.state { - State::Marquee { start, cur, .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))), + State::Marquee { start, cur, enclosed, directional_inside, .. } => o.push(Overlay::SelectionMarquee { + rect: Rect::from_points(*start, *cur), + enclosed: marquee_enclosed(cx, *enclosed, *directional_inside), + pointer: *cur, + }), State::Corner(c) => o.extend(c.overlays(cx)), State::Pie(d) => o.extend(d.overlays(cx)), State::RotateAll(d) => o.extend(d.overlays(cx)), @@ -1544,7 +1578,7 @@ impl SelectionTool { self.state = if !m.shift && selected_hidden_at(cx.doc, p, cx.selection_hit_options(), &cx.selection.objects).is_some() { State::Moving { start: grab, press: p, hold: grab, began: false, deselect: None, key: None, exclude: None } } else { - State::Marquee { start: p, cur: p, toggle: m.shift, enclosed: false } + State::Marquee { start: p, cur: p, toggle: m.shift, enclosed: false, directional_inside: false } }; } diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 242b9f928..9921d8f5b 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -79,6 +79,26 @@ fn a_press_on_a_masks_edge_selects_its_clip_group() { assert!(!a.iter().any(|a| matches!(a, Action::Exec(c, _) if c == "select.set")), "{a:?}"); } +/// #3748: in isolation mode a press on the art that the isolated clip group hides outside its +/// mask selects that art, and a drag from there moves it. Outside isolation mode the same +/// press starts a marquee. +#[test] +fn isolation_drags_the_art_a_clip_group_hides() { + let (d, g, _, art) = doc_with_masked_square(); + let (none, p) = (Selection::default(), paint()); + let mut picked = Selection::default(); + picked.add(art); + let mut t = SelectionTool::default(); + let a = t.pointer(&ToolContext { isolation: Some(g), ..cx(&d, &none, &p) }, &ev(PointerKind::Down, 530.0, 450.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({ "ids": [art.0] }))]); + let a = t.pointer(&ToolContext { isolation: Some(g), ..cx(&d, &picked, &p) }, &ev(PointerKind::Drag, 430.0, 450.0)); + let Some(Action::Preview(c, v)) = a.get(1) else { panic!("{a:?}") }; + assert_eq!((c.as_str(), v["matrix"][4].as_f64(), v["matrix"][5].as_f64()), ("object.transform", Some(-100.0), Some(0.0))); + let mut t = SelectionTool::default(); + assert!(t.pointer(&cx(&d, &none, &p), &ev(PointerKind::Down, 530.0, 450.0)).is_empty()); + assert!(matches!(t.state, State::Marquee { .. }), "not isolated"); +} + /// The art of a selected clipped object (picked in the Layers panel), pressed where its mask /// hides it, is grabbed: a drag moves the selection (the art, not its clip group) and a click /// keeps it selected. With nothing of it selected, or with Shift, a press there starts a marquee. @@ -1307,7 +1327,7 @@ fn escape_cancels_a_marquee() { t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0)); assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "Escape cancels the marquee"); assert!(!t.busy()); - assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Marquee(_))), "the marquee is removed"); + assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Marquee(_) | Overlay::SelectionMarquee { .. })), "the marquee is removed"); assert!(t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0)).is_empty(), "the release selects nothing"); } @@ -1380,6 +1400,58 @@ fn e_during_a_marquee_takes_only_the_objects_inside() { assert!(t.busy() && !t.claims_key(&cx, e)); } +/// Each rule selects what its feedback promises, with E reversing it for just this gesture. +#[test] +fn marquee_rules_and_feedback_agree() { + use crate::MarqueeMode::{Directional, Enclosed, Touching}; + let (d, id) = doc_with_rect(); + let (s, paint) = (Selection::default(), paint()); + for (mode, right, expected) in + [(Touching, true, true), (Touching, false, true), (Enclosed, true, false), (Directional, true, false), (Directional, false, true)] + { + let c = ToolContext { marquee_mode: mode, ..cx(&d, &s, &paint) }; + for inverted in [false, true] { + let mut tool = SelectionTool::default(); + let (a, b) = if right { ((50.0, 50.0), (150.0, 150.0)) } else { ((150.0, 50.0), (50.0, 150.0)) }; + tool.pointer(&c, &ev(PointerKind::Down, a.0, a.1)); + tool.pointer(&c, &ev(PointerKind::Drag, b.0, b.1)); + if inverted { + tool.key(&c, ToolKey::Letter('e'), Mods::default()); + } + let selected = expected != inverted; + assert!( + tool.overlays(&c).iter().any(|o| matches!(o, Overlay::SelectionMarquee { enclosed, .. } if *enclosed != selected)), + "{mode:?} right={right} E={inverted}" + ); + let out = tool.pointer(&c, &ev(PointerKind::Up, b.0, b.1)); + assert_eq!(out, vec![Action::Exec("select.set".into(), json!({"ids": if selected { vec![id.0] } else { vec![] }}))]); + assert!(!tool.overlays(&c).iter().any(|o| matches!(o, Overlay::SelectionMarquee { .. }))); + } + } +} + +/// Screen direction survives view rotation; near the press's x position, the last mode is kept. +#[test] +fn directional_marquee_uses_screen_direction_and_a_dead_zone() { + let (d, _) = doc_with_rect(); + let (s, paint) = (Selection::default(), paint()); + for angle in [0.0, 45.0, 90.0, 180.0, 270.0] { + for zoom in [0.25, 1.0, 4.0] { + let mut screen = turned_view(angle).unwrap(); + screen.right /= zoom; + screen.down /= zoom; + let c = ToolContext { marquee_mode: crate::MarqueeMode::Directional, screen: Some(screen), zoom, ..cx(&d, &s, &paint) }; + let start = screen.at(20.0, 20.0); + assert!(marquee_direction(&c, start, screen.at(24.0, 60.0), false)); + assert!(!marquee_direction(&c, start, screen.at(16.0, 60.0), true)); + for x in [18.0, 20.0, 22.0] { + assert!(marquee_direction(&c, start, screen.at(x, 60.0), true)); + assert!(!marquee_direction(&c, start, screen.at(x, 60.0), false)); + } + } + } +} + /// With E pressed during the drag, a marquee takes only the artboard guides that lie entirely /// inside it, and no canvas guide, which runs across the whole canvas. #[test] @@ -2025,7 +2097,10 @@ fn a_dropped_press_keeps_the_text_loaded() { t.pointer(&cx, &ev(PointerKind::Drag, 80.0, 90.0)); assert!(t.drop_press(&cx).is_empty()); assert!(t.pointer(&cx, &ev(PointerKind::Up, 80.0, 90.0)).is_empty(), "the release threads nothing"); - assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Marquee(_))), "the new frame's outline is removed"); + assert!( + !t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Marquee(_) | Overlay::SelectionMarquee { .. })), + "the new frame's outline is removed" + ); assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); // A press on the port that loaded the text puts it down; dropped, it leaves the text loaded. t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)); @@ -2243,7 +2318,7 @@ fn a_press_on_the_centre_point_moves_an_unfilled_shape() { t.pointer(&c, &ev(PointerKind::Down, 400.0, 400.0)); let a = t.pointer(&c, &ev(PointerKind::Drag, 450.0, 450.0)); assert!(a.is_empty(), "{a:?}"); - assert!(matches!(t.overlays(&c).first(), Some(Overlay::Marquee(_))), "a marquee"); + assert!(matches!(t.overlays(&c).first(), Some(Overlay::SelectionMarquee { .. })), "a marquee"); } /// A Shift-press on a selected object's centre point moves the selection too, held to 45° steps, diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index 076525be4..163117085 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. //! //! The selection keeps its bounding box and its Live Shape widgets on show with the Rectangle, //! Rounded Rectangle, Ellipse, Polygon and Star tools, so a shape just drawn can be adjusted at @@ -71,6 +72,9 @@ 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, /// A widget of the selection is being dragged instead of a shape drawn. widget: Option, /// The selection is being moved or turned instead of a shape drawn. @@ -84,6 +88,15 @@ pub struct ShapeTool { snapped: bool, } +/// 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 /// point's sides in line with the next points' sides: a regular star polygon {n/2}); none for /// fewer than five points, whose shoulders can't line up. @@ -117,6 +130,7 @@ impl ShapeTool { line_length: 100.0, line_angle: 0.0, fill_line: false, + before_drag: None, widget: None, selection: None, guides: vec![], @@ -125,6 +139,18 @@ impl ShapeTool { } } + 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; + } + /// A press on a corner or pie widget of the selection: the drag adjusts it. fn press_widget(&mut self, cx: &ToolContext, ev: &PointerEvent) -> bool { if !shows_shape_widgets(self.id) { @@ -428,6 +454,7 @@ impl Tool for ShapeTool { self.last = ev.pos; self.began = false; self.inner = None; + self.before_drag = None; vec![] } PointerKind::Drag => { @@ -451,6 +478,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); @@ -461,6 +489,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] @@ -528,6 +557,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 { @@ -659,6 +691,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 85a2d52fc..580f15c16 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/crates/tools/src/text.rs b/crates/tools/src/text.rs index c7a5a1ad4..bd0c229dd 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -36,7 +36,7 @@ use std::sync::{Arc, Mutex, PoisonError}; use serde_json::{Value, json}; use vectorcraft_doc::hit::hit_test; -use vectorcraft_doc::{NodeId, NodeKind, TextKind, TextObject, TextRun}; +use vectorcraft_doc::{NodeId, NodeKind, Quotes, TextKind, TextObject, TextRun}; use vectorcraft_geom::{BezPath, Point, Rect, Shape}; use vectorcraft_text::{FontDb, TextLayout, edit}; @@ -160,7 +160,11 @@ fn next_edit_session() -> u64 { pub struct TypeTool { vertical: bool, mode: Mode, - /// The tool to return to on Escape (e.g. when double-clicked from a selection tool). + /// The tool whose double-click on type chose this tool, which Escape and Cmd+Enter return to. + /// The canvas sets it; choosing the Type tool another way (the toolbar) leaves it unset, and + /// Escape then goes to the Selection tool. Clicks that end editing or move it to other text + /// keep it. Choosing another tool clears it, and so does deactivating this tool when another + /// document or another window of the document becomes active. prev_tool: Option, /// Text object being edited. editing: Option, @@ -327,11 +331,26 @@ impl TypeTool { return None; } // Use Typographer's Quotes (Document Setup): a typed straight quote becomes the document's - // opening or closing quote, by the character before it. + // opening or closing quote, by the character before it. A `”` typed alone (the macOS Serbian + // Cyrillic layout's only double quote) becomes the opening quote where one opens, and stays + // `”` where one closes. let setup = &cx.doc.setup; - if setup.typographers_quotes && matches!(s.as_str(), "\"" | "'") { - let mut prev = self.char_before(cx, self.caret.min(self.anchor)); - return Some(setup.quotes.apply(&s, &mut prev)); + if setup.typographers_quotes { + let mut chars = s.chars(); + if let (Some(c), None) = (chars.next(), chars.next()) { + if matches!(c, '"' | '\'') { + let mut prev = self.char_before(cx, self.caret.min(self.anchor)); + return Some(setup.quotes.apply(&s, &mut prev)); + } + if cx.typed_from_keyboard + && let Some(q) = Quotes::typed_as_straight(c) + { + let mut prev = self.char_before(cx, self.caret.min(self.anchor)); + if setup.quotes.opens_after(prev) { + return Some(setup.quotes.apply(&q.to_string(), &mut prev)); + } + } + } } Some(s) } @@ -396,8 +415,8 @@ impl TypeTool { out } - /// Stop editing and go back to the tool used before double-clicking into type (or to the - /// Selection tool), and leave the type selected. + /// Stop editing and go back to the tool whose double-click on type chose this tool (or to the + /// Selection tool when none did), and leave the type selected. fn end_editing(&mut self, cx: &ToolContext) -> Vec { let target = self.prev_tool.take().unwrap_or_else(|| "selection".into()); let mut out = self.finish(cx); @@ -460,6 +479,43 @@ impl TypeTool { out } + /// Tab (`by` 1) or Shift+Tab (`by` -1) in type `t` (plain text `text`) with the selection + /// `a..b`: with the caret at the start of a list paragraph, the `text.setFormat` that moves the + /// paragraph a level down or up (none at the first or the last level: the key then does + /// nothing). None when the caret is anywhere else, the selection holds characters, or the + /// paragraph is in no list. The start of a threaded frame whose first paragraph continues from + /// the frame before ([`TextObject::first_para_continues`]) is not the start of a paragraph. + fn list_level_step(&self, t: &TextObject, text: &str, a: usize, b: usize, by: i32) -> Option> { + let id = self.editing?; + if a != b || edit::paragraph_at(text, a).start != a || (a == 0 && t.first_para_continues) { + return None; + } + let para = text.get(..a)?.matches('\n').count(); + let list = t.para_at(para).list?; + let level = i32::try_from(list.depth()).ok()? + by; + let max = i32::try_from(vectorcraft_doc::text::LIST_LEVELS).ok()?; + if !(1..=max).contains(&level) { + return Some(vec![]); + } + Some(vec![Action::Exec("text.setFormat".into(), json!({"id": id.0, "start": a, "end": a, "listLevel": level}))]) + } + + /// Tab (Shift+Tab with `shift`) in type `t` (plain text `text`) with the selection `a..b`: with + /// the caret at the start of a list paragraph, it moves the paragraph a level down or up + /// ([`Self::list_level_step`]); anywhere else Tab inserts a tab. + fn tab(&mut self, cx: &ToolContext, t: &TextObject, text: &str, (a, b): (usize, usize), shift: bool) -> Vec { + match self.list_level_step(t, text, a, b, if shift { -1 } else { 1 }) { + // At the first or the last level the key does nothing, and the typing goes on. + Some(step) if step.is_empty() => step, + Some(step) => { + let mut out = self.commit(); + out.extend(step); + out + } + None => self.replace(cx, "\t", None), + } + } + /// The other frames whose whole text is selected along with the edited frame's after Select /// All in threaded text ([`Self::story`]), read from the document's current thread: none once /// the selection is not all of the edited frame's text. @@ -521,7 +577,11 @@ impl TypeTool { /// from the start of the caret's paragraph up to it start a list ([`TextObject::typed_list_at`]), /// the typing session ends as one undo step with those characters, and `text.startList` /// removes them and makes the paragraph a list as the next one, so one Undo puts them back. + /// With Type › Start Lists From Typed Markers off, the characters stay as typed. fn start_typed_list(&mut self, cx: &ToolContext) -> Vec { + if !cx.typed_lists { + return vec![]; + } let (Some(id), Some(ty)) = (self.editing, &self.typing) else { return vec![] }; let Some(mut t) = Self::text(cx, id).cloned() else { return vec![] }; // The document does not have the space yet (its preview is applied after this input). diff --git a/crates/tools/src/text/tests.rs b/crates/tools/src/text/tests.rs index 4ae1424f6..a7f302db6 100644 --- a/crates/tools/src/text/tests.rs +++ b/crates/tools/src/text/tests.rs @@ -535,6 +535,105 @@ fn backspace_deletes_selection_or_word() { assert_eq!(tool.caret, 6); } +/// Tab and Shift+Tab with the caret at the start of a list paragraph move it a level down or up +/// with one `text.setFormat` (one undo step), ending the typing first; at level 1 Shift+Tab and at +/// level 9 Tab do nothing. Anywhere else Tab inserts a tab: in the middle or at the end of a list +/// paragraph, with characters selected, and in a paragraph in no list. +#[test] +fn tab_at_the_start_of_a_list_paragraph_changes_its_level() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let (mut d, id, mut tool) = editing("One\nTwo\nThree"); + let NodeKind::Text(t) = &mut d.node_mut(id).unwrap().kind else { panic!("text") }; + let dot = |level| vectorcraft_doc::ParaStyle { list: Some(ParaList::bulleted(Bullet::Dot).at_level(level)), ..Default::default() }; + t.set_paragraph_styles(vec![dot(1), dot(9), vectorcraft_doc::ParaStyle::default()]); + let press = |tool: &mut TypeTool, at: (usize, usize), mods: Mods| { + (tool.anchor, tool.caret) = at; + key(tool, &d, ToolKey::Tab, mods) + }; + let level = |acts: &[Action]| match acts { + [Action::Exec(cmd, p)] if cmd == "text.setFormat" => { + assert_eq!((p["id"].as_u64(), p["end"].as_u64()), (Some(id.0), p["start"].as_u64())); + (p["start"].as_u64(), p["listLevel"].as_i64()) + } + other => panic!("not a level change: {other:?}"), + }; + assert_eq!(level(&press(&mut tool, (0, 0), Mods::default())), (Some(0), Some(2))); + assert_eq!(level(&press(&mut tool, (4, 4), SHIFT)), (Some(4), Some(8))); + assert!(press(&mut tool, (0, 0), SHIFT).is_empty(), "Shift+Tab at level 1 does nothing"); + assert!(press(&mut tool, (4, 4), Mods::default()).is_empty(), "Tab at level 9 does nothing"); + let inserts_tab = |acts: &[Action], at: u64| { + acts.iter().any( + |a| matches!(a, Action::Preview(cmd, p) if cmd == "text.editRange" && p["start"].as_u64() == Some(at) && p["runs"][0]["text"] == "\t"), + ) + }; + for (at, why) in [((1, 1), "mid-item"), ((3, 3), "at the item's end"), ((0, 2), "with characters selected"), ((8, 8), "in no list")] { + let mut fresh = TypeTool::new("type"); + fresh.start_editing(id, 0); + let acts = press(&mut fresh, at, Mods::default()); + assert!(inserts_tab(&acts, at.0 as u64), "{why}: {acts:?}"); + } + // While typing, Tab at the start of a list paragraph ends the typing first. + let mut typing = TypeTool::new("type"); + typing.start_editing(id, 3); + let snapshot = d.clone(); + let mut after = d.clone(); + let acts = typing.text_input(&cx(&d, &Selection::default(), &paint()), "!"); + apply(&mut after, &snapshot, &acts); + // Tab at level 9 does nothing, and the typing goes on as one undo step. + (typing.anchor, typing.caret) = (5, 5); + let acts = typing.key(&cx(&after, &Selection::default(), &paint()), ToolKey::Tab, Mods::default()); + assert!(acts.is_empty() && typing.typing.is_some(), "Tab at level 9 while typing: {acts:?}"); + let acts = { + (typing.anchor, typing.caret) = (5, 5); + typing.key(&cx(&after, &Selection::default(), &paint()), ToolKey::Tab, SHIFT) + }; + assert_eq!(acts.first(), Some(&Action::Commit)); + assert!(matches!(&acts[1..], [Action::Exec(cmd, p)] if cmd == "text.setFormat" && p["listLevel"] == 8 && p["start"] == 5), "{acts:?}"); +} + +/// In a threaded frame, the start of the frame is the start of a list paragraph only when the +/// frames before it end with a paragraph break: Tab there in a paragraph that goes on from the +/// frame before inserts a tab, and Tab after the paragraph's end changes the next one's level. +#[test] +fn tab_at_the_start_of_a_threaded_frame_changes_a_level_only_at_a_paragraph_start() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let dot = vectorcraft_doc::ParaStyle { list: Some(ParaList::bulleted(Bullet::Dot)), ..Default::default() }; + let threaded = |first: &str, second: &str| { + let (mut d, a) = doc_with_text(first); + let l = d.layers[0].id; + let b = d.alloc_id(); + let mut t = TextObject::point(Point::new(100.0, 300.0), second, CharStyle { size: 20.0, ..Default::default() }); + t.set_paragraph_styles(vec![dot.clone(); second.matches('\n').count() + 1]); + let NodeKind::Text(head) = &d.node(a).unwrap().kind else { panic!("text") }; + t.first_para_continues = vectorcraft_text::thread::continued(&[head, &t])[1]; + d.insert(Some(l), 0, Node::new(b, NodeKind::Text(Box::new(t)))).unwrap(); + let NodeKind::Text(t) = &mut d.node_mut(a).unwrap().kind else { panic!("text") }; + t.set_paragraph_styles(vec![dot.clone(); first.matches('\n').count() + 1]); + d.text_threads.push(vec![a, b]); + let mut tool = TypeTool::new("type"); + tool.start_editing(b, 0); + (d, b, tool) + }; + let level_change = |acts: &[Action]| matches!(acts, [Action::Exec(cmd, p)] if cmd == "text.setFormat" && p["listLevel"] == 2); + let inserts_tab = + |acts: &[Action]| acts.iter().any(|a| matches!(a, Action::Preview(cmd, p) if cmd == "text.editRange" && p["runs"][0]["text"] == "\t")); + // "Two" goes on from the first frame: Tab at the frame's start inserts a tab, and Tab at the + // start of "Three" changes its level. + let (d, _, mut tool) = threaded("One\nTw", "o\nThree"); + let acts = key(&mut tool, &d, ToolKey::Tab, Mods::default()); + assert!(inserts_tab(&acts), "mid-paragraph: {acts:?}"); + let (d, _, mut tool) = threaded("One\nTw", "o\nThree"); + (tool.anchor, tool.caret) = (2, 2); + let acts = key(&mut tool, &d, ToolKey::Tab, Mods::default()); + assert!(level_change(&acts), "at the next paragraph: {acts:?}"); + // A frame after a paragraph break, or after frames with no text, starts a paragraph. + for first in ["One\n", ""] { + let (d, _, mut tool) = threaded(first, "Two"); + let acts = key(&mut tool, &d, ToolKey::Tab, Mods::default()); + assert!(level_change(&acts), "after {first:?}: {acts:?}"); + } +} + #[test] fn double_and_triple_click_select_word_and_paragraph() { let (d, id, mut tool) = editing("Alpha beta\ngamma"); @@ -1740,3 +1839,31 @@ fn shift_enter_inserts_soft_return() { let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() }; assert_eq!(t.plain_text(), "Hello\u{2028}\n World"); } + +#[test] +fn soft_return_in_a_list_item_keeps_tab_within_the_paragraph() { + use vectorcraft_doc::text::{Bullet, ParaList}; + + let (mut d, id, mut tool) = editing("One"); + let NodeKind::Text(t) = &mut d.node_mut(id).unwrap().kind else { panic!("text") }; + t.set_all_paras(vectorcraft_doc::ParaStyle { list: Some(ParaList::bulleted(Bullet::Dot)), ..Default::default() }); + tool.caret = 3; + tool.anchor = 3; + let snapshot = d.clone(); + let acts = key(&mut tool, &d, ToolKey::Enter, SHIFT); + apply(&mut d, &snapshot, &acts); + let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!("text") }; + assert_eq!(t.plain_text(), "One\u{2028}"); + assert_eq!(t.paragraph_count(), 1); + assert_eq!(t.para_at(0).list, Some(ParaList::bulleted(Bullet::Dot))); + + let acts = key(&mut tool, &d, ToolKey::Tab, Mods::default()); + assert!(acts.iter().any( + |a| matches!(a, Action::Preview(cmd, p) if cmd == "text.editRange" && p["runs"][0]["text"].as_str().is_some_and(|s| s.ends_with('\t'))) + )); + + tool.caret = 0; + tool.anchor = 0; + let acts = key(&mut tool, &d, ToolKey::Tab, Mods::default()); + assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, p) if cmd == "text.setFormat" && p["listLevel"] == 2))); +} diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs index 9f97f2372..dd561bc61 100644 --- a/crates/tools/src/text/tool.rs +++ b/crates/tools/src/text/tool.rs @@ -25,7 +25,8 @@ impl Tool for TypeTool { fn wants_text(&self) -> bool { self.editing.is_some() } - /// Escape while editing is the tool's: it goes on to the Selection tool (#1686). + /// Escape while editing is the tool's: it goes back to the tool whose double-click on type + /// chose this tool, or to the Selection tool when none did (#1686). fn claims_key(&self, _: &ToolContext, key: ToolKey) -> bool { key == ToolKey::Escape && self.editing.is_some() } @@ -163,7 +164,7 @@ impl Tool for TypeTool { // commit this check still sees the pre-edit at the caret, while `text_input` replaces it // from its start; with a custom pair whose opening quote is straight, that can look // verbatim here and then be rewritten. Never claim verbatim for one. - if cx.doc.setup.typographers_quotes && matches!(s, "\"" | "'") { + if cx.doc.setup.typographers_quotes && (matches!(s, "\"" | "'") || (cx.typed_from_keyboard && is_one_typed_curly_quote(s))) { return false; } let Some(normalized) = self.input_text(cx, s) else { return false }; @@ -230,7 +231,8 @@ impl Tool for TypeTool { // tool shortcuts again (#1686). ToolKey::Escape => self.end_editing(cx), ToolKey::Enter => self.enter(cx, mods), - ToolKey::Tab => self.replace(cx, "\t", None), + ToolKey::Tab if story => self.replace(cx, "\t", None), + ToolKey::Tab => self.tab(cx, &t, &text, (a, b), mods.shift), ToolKey::Backspace | ToolKey::Delete if a != b || story => self.replace(cx, "", None), ToolKey::Backspace if self.caret > 0 => { self.anchor = if word { edit::prev_word(&text, self.caret) } else { edit::prev_char(&text, self.caret) }; @@ -715,3 +717,10 @@ impl Tool for TypeTool { } } } + +/// Is `s` one curly quote that Typographer's Quotes may set like a straight one +/// ([`vectorcraft_doc::Quotes::typed_as_straight`]: a lone `”`)? +fn is_one_typed_curly_quote(s: &str) -> bool { + let mut chars = s.chars(); + matches!((chars.next(), chars.next()), (Some(c), None) if vectorcraft_doc::Quotes::typed_as_straight(c).is_some()) +} diff --git a/crates/tools/src/xform/artboard.rs b/crates/tools/src/xform/artboard.rs index c3710c1bd..4ffd435af 100644 --- a/crates/tools/src/xform/artboard.rs +++ b/crates/tools/src/xform/artboard.rs @@ -21,8 +21,17 @@ //! snap too. A new artboard's corners snap as drawn points do ([`DrawSnap`]). A locked artboard (the //! Artboards panel's lock column) is selected by a click, but a drag or an arrow key doesn't move it //! or copy it, it shows no handles, and Delete leaves it. +//! +//! While one artboard is selected and nothing is being dragged, a button with a plus sits just +//! outside the middle of each of its edges where an artboard of the same size fits on that side +//! ([`vectorcraft_doc::Document::room_beside_artboard`]: within the canvas, 20 pt away, overlapping +//! no other artboard). Clicking one adds an empty artboard there (`artboard.new {beside, side}`); +//! Alt-clicking duplicates the artboard with its art there (`artboard.duplicate {index, side}`), +//! except on a locked artboard, where an Alt-click does nothing. Each is one undo step, and the new +//! artboard becomes the active one. use serde_json::{Value, json}; +use vectorcraft_doc::ArtboardSide; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use super::{BLUE, polygon, rect_corners}; @@ -282,6 +291,44 @@ fn bleed_offset(cx: &ToolContext, rect: Rect, handle: Handle) -> Vec2 { Vec2::new(if ax { d.x } else { 0.0 }, if ay { d.y } else { 0.0 }) } +/// How far outside the middle of an edge its add button sits, in screen pixels: past the handle +/// on that edge. +const EDGE_BUTTON_OFFSET: f64 = 18.0; +/// The add button's radius in screen pixels: a press this near its center takes it. +const EDGE_BUTTON_RADIUS: f64 = 7.0; + +impl ArtboardTool { + /// The add buttons on the active artboard's edges, each with its side and center: shown while + /// it is the only one selected and nothing is being dragged, and only on the sides with room + /// for an artboard of the same size ([`vectorcraft_doc::Document::room_beside_artboard`]). + fn edge_buttons(&self, cx: &ToolContext) -> Vec<(ArtboardSide, Point)> { + let Some(r) = self.active_rect(cx) else { return vec![] }; + if self.drag.is_some() || self.selected(cx).len() != 1 { + return vec![]; + } + let off = cx.tol(EDGE_BUTTON_OFFSET); + let c = r.center(); + ArtboardSide::ALL + .into_iter() + .filter(|side| cx.doc.room_beside_artboard(self.active, *side).is_some()) + .map(|side| { + let at = match side { + ArtboardSide::Left => Point::new(r.x0 - off, c.y), + ArtboardSide::Right => Point::new(r.x1 + off, c.y), + ArtboardSide::Top => Point::new(c.x, r.y0 - off), + ArtboardSide::Bottom => Point::new(c.x, r.y1 + off), + }; + (side, at) + }) + .collect() + } + + /// The add button under `p`, if any. + fn edge_button_at(&self, cx: &ToolContext, p: Point) -> Option { + self.edge_buttons(cx).into_iter().find(|(_, at)| at.distance(p) <= cx.tol(EDGE_BUTTON_RADIUS)).map(|(side, _)| side) + } +} + /// The grid's snapping step (gridline spacing over subdivisions), as `guides::snap_draw` uses. impl Tool for ArtboardTool { fn id(&self) -> &'static str { @@ -305,6 +352,21 @@ impl Tool for ArtboardTool { match (ev.kind, self.drag) { (PointerKind::Down, _) => { self.click_art = None; + // An add button: an empty artboard of the same size on that side, or with Alt a + // copy of the artboard and its art there; the new one becomes active. An Alt-click + // on a locked artboard's button does nothing: the tool doesn't copy a locked artboard. + if let Some(side) = self.edge_button_at(cx, p) { + let index = self.active; + if m.alt && cx.doc.artboards.get(index).is_some_and(|a| a.locked) { + return vec![]; + } + let (id, v) = if m.alt { + ("artboard.duplicate", json!({ "index": index, "side": side.name(), "art": true })) + } else { + ("artboard.new", json!({ "beside": index, "side": side.name() })) + }; + return vec![Action::Exec(id.into(), v), Action::Notify("created".into())]; + } if let Some(r) = self.resizable_rect(cx) && let Some(handle) = hit_handle(r, p, cx.tol(5.0)) { @@ -593,10 +655,24 @@ impl Tool for ArtboardTool { if self.drag.is_some() { o.extend(self.guides.iter().cloned()); } + // The add buttons: a ring with a plus in it. + let (radius, arm) = (cx.tol(EDGE_BUTTON_RADIUS), cx.tol(3.5)); + for (_, at) in self.edge_buttons(cx) { + let ring: Vec = (0..24).map(|k| at + Vec2::from_angle(f64::from(k) * std::f64::consts::TAU / 24.0) * radius).collect(); + let mut path = polygon(&ring, true); + for arm in [Vec2::new(arm, 0.0), Vec2::new(0.0, arm)] { + path.move_to(at - arm); + path.line_to(at + arm); + } + o.push(Overlay::Path { path, color: BLUE, width: 1.0, dashed: false }); + } o } fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { + if self.edge_button_at(cx, p).is_some() { + return Cursor::Arrow; + } if let Some(r) = self.resizable_rect(cx) && let Some(h) = hit_handle(r, p, cx.tol(5.0)) { diff --git a/crates/tools/src/xform/artboard/tests.rs b/crates/tools/src/xform/artboard/tests.rs index 3c8900ff5..98410703f 100644 --- a/crates/tools/src/xform/artboard/tests.rs +++ b/crates/tools/src/xform/artboard/tests.rs @@ -562,3 +562,97 @@ fn alt_drag_moves_a_copy_of_the_artboard() { assert_eq!(a, vec![Action::Cancel, Action::Begin("Duplicate Artboard".into()), moved(70.0, true)]); assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 170.0, 100.0).with_mods(alt)), vec![Action::Commit]); } + +/// The sides of the active artboard that show an add button. +fn button_sides(t: &ArtboardTool, c: &ToolContext) -> Vec { + t.edge_buttons(c).into_iter().map(|(side, _)| side).collect() +} + +/// The rings the add buttons draw. +fn button_rings(t: &ArtboardTool, c: &ToolContext) -> usize { + t.overlays(c).iter().filter(|o| matches!(o, Overlay::Path { dashed: false, .. })).count() +} + +#[test] +fn add_buttons_show_only_on_sides_with_room_for_an_artboard_of_the_same_size() { + let mut d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let t = ArtboardTool::default(); + // Artboard 1 (0–500): a copy on its right (520–1020) would overlap artboard 2 (600–800). + let c = cx(&d, &s, &p); + assert_eq!(button_sides(&t, &c), vec![ArtboardSide::Top, ArtboardSide::Bottom, ArtboardSide::Left]); + assert_eq!(button_rings(&t, &c), 3); + // Artboard 2 (600–800 × 0–200): its left would overlap artboard 1, the rest has room. + let t2 = ArtboardTool { active: 1, ..Default::default() }; + assert_eq!(button_sides(&t2, &c), vec![ArtboardSide::Top, ArtboardSide::Right, ArtboardSide::Bottom]); + // At the canvas's edge there is no room past it. + let m = vectorcraft_doc::MAX_COORD; + d.artboards[1].rect = Rect::new(m - 200.0, 0.0, m, 200.0); + let c = cx(&d, &s, &p); + assert_eq!(button_sides(&t2, &c), vec![ArtboardSide::Top, ArtboardSide::Bottom, ArtboardSide::Left]); + // The buttons sit just outside the middle of each edge. + let off = c.tol(EDGE_BUTTON_OFFSET); + assert_eq!(t.edge_buttons(&c)[0].1, Point::new(250.0, -off)); +} + +#[test] +fn add_buttons_hide_with_several_artboards_selected_or_during_a_drag() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let t = ArtboardTool { active: 1, others: vec![0], ..Default::default() }; + assert!(button_sides(&t, &c).is_empty()); + assert_eq!(button_rings(&t, &c), 0); + let off = c.tol(EDGE_BUTTON_OFFSET); + assert_eq!(t.edge_button_at(&c, Point::new(700.0, 200.0 + off)), None, "no button takes a press"); + let mut t = ArtboardTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0)); + t.pointer(&c, &ev(PointerKind::Drag, 720.0, 100.0)); + assert!(button_sides(&t, &c).is_empty(), "none while an artboard is dragged"); +} + +#[test] +fn an_add_button_adds_an_empty_artboard_and_alt_duplicates_on_that_side() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let off = c.tol(EDGE_BUTTON_OFFSET); + let below = Point::new(250.0, 500.0 + off); + let mut t = ArtboardTool::default(); + assert_eq!(t.cursor(&c, below, Mods::default()), Cursor::Arrow); + let a = t.pointer(&c, &ev(PointerKind::Down, below.x, below.y)); + assert_eq!(a, vec![Action::Exec("artboard.new".into(), json!({"beside": 0, "side": "bottom"})), Action::Notify("created".into())]); + // The release that follows adds nothing more. + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, below.x, below.y)), vec![]); + let alt = Mods { alt: true, ..Mods::default() }; + let left = Point::new(-off, 250.0); + let a = t.pointer(&c, &ev(PointerKind::Down, left.x, left.y).with_mods(alt)); + assert_eq!( + a, + vec![Action::Exec("artboard.duplicate".into(), json!({"index": 0, "side": "left", "art": true})), Action::Notify("created".into())] + ); + // The new artboard, last in the document, becomes the active one. + let mut d3 = d.clone(); + d3.artboards.push(d.artboards[0].clone()); + t.notify(&cx(&d3, &s, &p), "created"); + assert_eq!((t.active, t.others.clone()), (2, vec![])); +} + +/// On a locked artboard, a click on an add button still adds an empty artboard beside it, but an +/// Alt-click does nothing: the tool doesn't copy a locked artboard. +#[test] +fn an_alt_click_on_a_locked_artboards_add_button_does_not_duplicate_it() { + let mut d = two_boards(); + d.artboards[0].locked = true; + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let off = c.tol(EDGE_BUTTON_OFFSET); + let left = Point::new(-off, 250.0); + let mut t = ArtboardTool::default(); + let alt = Mods { alt: true, ..Mods::default() }; + assert_eq!(t.pointer(&c, &ev(PointerKind::Down, left.x, left.y).with_mods(alt)), vec![]); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, left.x, left.y).with_mods(alt)), vec![]); + assert_eq!((t.active, t.others.clone()), (0, vec![])); + let a = t.pointer(&c, &ev(PointerKind::Down, left.x, left.y)); + assert_eq!(a, vec![Action::Exec("artboard.new".into(), json!({"beside": 0, "side": "left"})), Action::Notify("created".into())]); +} diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index 7a9b59e2d..d984504e1 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -444,6 +444,11 @@ impl GradientTool { let id = paint_owner(cx.doc, h.leaf); let Some(v) = cx.selection.objects.contains(&id).then(|| click_vector(cx, id)).flatten() else { return vec![] }; let mut params = Self::geom_params(cx, p, p + v); + // Without `ids`, the engine refuses the gradient while an object of a dynamic symbol + // instance's art is selected. + if cx.selection.symbol_part.is_some() { + return vec![Action::Exec("paint.setGradientGeom".into(), params)]; + } params["ids"] = json!([id.0]); // With `ids` the engine edits the topmost fill or stroke unless an item is named. if let Some(i) = cx.doc.node(id).and_then(|n| n.appearance.item_of_kind(cx.appearance_item, cx.fill_active)) { diff --git a/crates/tools/src/xform/gradient/tests.rs b/crates/tools/src/xform/gradient/tests.rs index 2542ec7c5..c6582152b 100644 --- a/crates/tools/src/xform/gradient/tests.rs +++ b/crates/tools/src/xform/gradient/tests.rs @@ -268,6 +268,31 @@ fn a_click_inside_the_art_applies_the_gradient_from_there() { assert!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(400.0, 400.0))).is_empty()); } +/// With an object of a dynamic symbol instance selected with Direct Selection, a click on the +/// instance sends the gradient without `ids`, which the engine refuses. +#[test] +fn a_click_on_a_dynamic_instance_with_a_picked_object_names_no_object() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, Symbol}; + let square = + Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let art = Node::group(NodeId(0), vec![Arc::new(square)]); + let (mut d, _) = doc_with_rect(); + d.symbols.push(Symbol { dynamic: true, name: "One".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "One".into(), xf: Affine::translate((300.0, 300.0)) })).unwrap(); + let mut s = Selection::default(); + s.set_symbol_part(id, vec![0]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GradientTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, Point::new(305.0, 305.0))); + let a = t.pointer(&cx, &ev(PointerKind::Up, Point::new(305.0, 305.0))); + let (c, v) = exec(&a[0]); + assert_eq!(c, "paint.setGradientGeom"); + assert!(v.get("ids").is_none() && v.get("item").is_none(), "{v}"); +} + #[test] fn double_click_on_a_stop_opens_its_popover() { let (d, s) = graded(two()); diff --git a/crates/trace/src/layers.rs b/crates/trace/src/layers.rs index d9b34cc54..5050f4a2d 100644 --- a/crates/trace/src/layers.rs +++ b/crates/trace/src/layers.rs @@ -1,7 +1,7 @@ use super::params::{Method, Mode, TraceParams}; +use super::pipeline::TracedPath; #[cfg(test)] -use super::pipeline::bw_tones; -use super::pipeline::{TracedPath, is_ignored}; +use super::pipeline::{bw_tones, left_out}; use super::raster::Raster; use super::{ AtomicUsize, Crack, Loop, Ordering, PathData, Quantized, TRANSPARENT, TracedGradient, TracedShape, centerline, contour, fit, logo, shapes, @@ -13,12 +13,15 @@ use super::{denoise, quantize}; /// What tracing one colour layer needs. pub(crate) struct Layers<'a> { pub(crate) q: &'a Quantized, + /// The label each layer traces a pixel as: `q.labels`, or with Ignore Color for the background + /// only, those with the background [`TRANSPARENT`]. Edges are placed by `q.labels`. + pub(crate) traced: &'a [u16], /// Each palette entry's layer, bottom (0) up. pub(crate) rank: &'a [usize], pub(crate) params: &'a TraceParams, pub(crate) img: &'a Raster, - /// The colour Ignore Color leaves out, if not white. - pub(crate) ignore: Option<[u8; 3]>, + /// For each palette entry, whether Ignore Color or Keep Colors leaves it out (see `pipeline::left_out`). + pub(crate) left_out: Vec, /// The box each palette entry's pixels lie in: `(x0, y0, x1, y1)`, the far sides excluded. pub(crate) boxes: Vec>, /// Gradients: the gradient each palette entry is, if it is one. @@ -35,10 +38,12 @@ pub(crate) struct Layers<'a> { } impl Layers<'_> { - /// Ignore White: white is no part of any layer (stacked layers reach under the layers above - /// them, and must not reach under white that is left out). + /// Ignore White, and the colors Keep Colors does not list: such a color is no part of any + /// layer (stacked layers reach under the layers above them, and must not reach under a color + /// that is left out). With `ignore_background` the background is [`TRANSPARENT`] in `traced`, + /// and the color's other areas are traced. fn ignored(&self, label: u16) -> bool { - self.params.ignore_white && self.q.palette.get(label as usize).is_some_and(|c| is_ignored(*c, self.ignore)) + self.left_out.get(label as usize).copied().unwrap_or(false) } /// The region across a crack, for cutting outlines at their junctions: the outside pixel's /// colour. Stacked layers end on many colours at once and share no edge: one neighbour. @@ -270,7 +275,7 @@ impl Layers<'_> { let (thin, lines) = if params.strokes { // The ink the lines are read against: the layer's colour; Black and White's own black. let ink = if params.mode == Mode::BlackAndWhite { [self.bw_tones[usize::from(ci == 1)]; 3] } else { color.map(f64::from) }; - centerline::lines(&self.q.labels, u16::try_from(ci).unwrap_or(TRANSPARENT), w, h, params.stroke_width, self.img, ink, &self.style) + centerline::lines(self.traced, u16::try_from(ci).unwrap_or(TRANSPARENT), w, h, params.stroke_width, self.img, ink, &self.style) } else { (vec![], vec![]) }; @@ -288,7 +293,7 @@ impl Layers<'_> { let (bw, bh) = (x1 - x0, y1 - y0); let mut mask = Vec::with_capacity(bw * bh); for y in y0..y1 { - let row = self.q.labels.get(y * w + x0..y * w + x1)?; + let row = self.traced.get(y * w + x0..y * w + x1)?; mask.extend(row.iter().enumerate().map(|(k, &l)| l != TRANSPARENT && !thin.get(y * w + x0 + k).copied().unwrap_or(false) && part(l))); } // Overlapping: a part of the mask with no pixel of this color holds only colors above it, @@ -433,10 +438,11 @@ pub(crate) fn shift_probe(img: &Raster, params: &TraceParams) -> Vec<[f64; 4]> { let gradients = vec![None; n]; let job = Layers { q: &q, + traced: &q.labels, rank: &rank, params, img, - ignore: None, + left_out: left_out(&q.palette, params, None, &[]), boxes: vec![None; n], gradients: &gradients, bw_tones: bw_tones(img, &q), diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index e68118d63..46c29f4e0 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -81,6 +81,8 @@ pub(crate) use layers::shift_probe; pub use params::{Method, Mode, PRESET_NAMES, Palette, TraceParams, enhanced_preset, preset, presets}; pub use pipeline::{TraceResult, TracedPath, trace, trace_watched, trace_within}; #[cfg(test)] +use pipeline::{clear_background, label_extents}; +#[cfg(test)] use raster::fit_size; pub use raster::{MAX_PIXELS, MAX_SOURCE_PIXELS, Raster, TraceError}; diff --git a/crates/trace/src/logo/pipeline.rs b/crates/trace/src/logo/pipeline.rs index 02955b146..b8382c5a7 100644 --- a/crates/trace/src/logo/pipeline.rs +++ b/crates/trace/src/logo/pipeline.rs @@ -353,22 +353,38 @@ fn plane_at(planes: &[Vec], class: usize, i: usize) -> f32 { planes.get(class).and_then(|p| p.get(i)).copied().unwrap_or(0.0) } -/// The unmixing classes for a palette: each colour, then white, then transparent. -fn classes_for(palette: &[[u8; 3]]) -> Vec<[f64; 4]> { +/// The unmixing classes for a palette: each colour, then white (when `own_white`), then transparent. +fn classes_for(palette: &[[u8; 3]], own_white: bool) -> Vec<[f64; 4]> { let mut classes: Vec<[f64; 4]> = palette.iter().map(|c| [f64::from(c[0]) / 255.0, f64::from(c[1]) / 255.0, f64::from(c[2]) / 255.0, 1.0]).collect(); - classes.push([1.0, 1.0, 1.0, 1.0]); + if own_white { + classes.push([1.0, 1.0, 1.0, 1.0]); + } classes.push([0.0, 0.0, 0.0, 0.0]); classes } +/// Whether white is traced as a class of its own: always, unless the caller named the colors and +/// turned [`TraceParams::logo_white`] off. With Ignore White it is, so white areas are left out. +fn own_white(params: &TraceParams) -> bool { + params.logo_white || params.logo_colors.is_empty() || params.ignore_white +} + +/// Without a white class of its own: the palette color that takes white's coverage, the one +/// nearest white (the first of equally near ones). `None` when a named color is white already +/// (closer than [`SAME_COLOUR`]), so white needs no class to unmix with. +fn white_goes_to(palette: &[[u8; 3]]) -> Option { + let (i, d) = palette.iter().enumerate().map(|(i, &c)| (i, distance(c, [255; 3]))).min_by(|a, b| a.1.total_cmp(&b.1))?; + (d >= SAME_COLOUR).then_some(i) +} + fn distance(a: [u8; 3], b: [u8; 3]) -> f64 { (0..3).map(|k| (f64::from(a[k]) - f64::from(b[k])).powi(2)).sum::().sqrt() } type Colours = Vec<[u8; 3]>; -/// The colours to trace: those the caller named (white is always its own class so it is skipped; +/// The colours to trace: those the caller named (white is skipped when it is its own class; /// colours closer than [`SAME_COLOUR`] count as one; at most [`MAX_COLOURS`]) or the ones found, /// the tones of one colour folded into it ([`fold_tones`]); and with them the colours before folding. fn choose_palette(params: &TraceParams, pix: &[Px], w: usize, h: usize) -> Result<(Colours, Colours), TraceError> { @@ -380,7 +396,7 @@ fn choose_palette(params: &TraceParams, pix: &[Px], w: usize, h: usize) -> Resul for s in ¶ms.logo_colors { // Every entry is checked, but only the first few distinct colours are kept. let c = parse_hex(s).ok_or_else(|| TraceError::BadPalette(s.clone()))?; - let white = c.iter().all(|&v| v >= WHITE_MIN); + let white = own_white(params) && c.iter().all(|&v| v >= WHITE_MIN); if palette.len() < MAX_COLOURS && !white && !palette.iter().any(|&q| distance(q, c) < SAME_COLOUR) { palette.push(c); } @@ -405,7 +421,11 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize) if fraction < MIN_FLAT { return Err(TraceError::NotFlat { fraction }); } - let mut classes = classes_for(&palette); + let own_white = own_white(params); + // Without a white of its own, white is still unmixed as a class, so blended edges split where + // they are, and its coverage is then added to the color it goes to. + let merge_white = if own_white { None } else { white_goes_to(&palette) }; + let mut classes = classes_for(&palette, own_white || merge_white.is_some()); // A very busy image is averaged down further until the unmixing work is bounded. for _ in 0..6 { if count_ambiguous(&pix, &classes) <= EDGE_BUDGET || w * h <= 16_384 { @@ -423,15 +443,28 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize) palette.extend(extra.into_iter().take(MAX_COLOURS - palette.len())); // (A colour nothing explained may be one more tone of a colour that is there.) palette = fold_tones(&pix, palette); - classes = classes_for(&palette); + classes = classes_for(&palette, own_white); unmix(&pix, w, h, &classes).0 } } else { std::mem::take(&mut planes) }; - let (white, transparent) = (palette.len(), palette.len() + 1); + if let Some(to) = merge_white + && palette.len() < planes.len() + { + let white_plane = planes.remove(palette.len()); + if let Some(target) = planes.get_mut(to) { + for (t, v) in target.iter_mut().zip(&white_plane) { + *t += *v; + } + } + } + // Without a white class, `white` is an index no class has. + let (white, transparent) = if own_white { (palette.len(), palette.len() + 1) } else { (usize::MAX, palette.len()) }; let aliased = soften_if_aliased(&mut planes, w, h); - fill_knockouts(&mut planes, w, h, white, transparent); + if own_white { + fill_knockouts(&mut planes, w, h, white, transparent); + } let n = w * h; let dominant: Vec = (0..n) .map(|i| (0..planes.len()).max_by(|&a, &b| plane_at(&planes, a, i).total_cmp(&plane_at(&planes, b, i))).unwrap_or(transparent) as u8) @@ -474,7 +507,7 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize) let to_source = ToSource { factor: factor as f64, work: (w, h), source: (w0, h0) }; let mut shapes: Vec = Vec::new(); let mut anchors_made = 0usize; - for class in 0..=white { + for class in 0..transparent { if class == white && params.ignore_white { continue; } @@ -488,8 +521,9 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize) if a2.abs() / 2.0 < min_area { continue; } - // Holes are cut only under Ignore White; otherwise white and the other colors are traced as their own shapes. - if a2 >= 0.0 && !params.ignore_white { + // Holes are cut only under Ignore White or with no white class; otherwise white and the other colors are + // traced as their own shapes. + if a2 >= 0.0 && !(params.ignore_white || !own_white) { continue; } // More real outlines than we will fit is refused, not silently truncated. @@ -499,8 +533,9 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize) let pts = to_pixels(&lp, u); if a2 < 0.0 { outers.push((a2.abs() / 2.0, pts)) } else { holes.push(pts) } } - // Each hole belongs to the smallest outline that contains it, and to no other. - let owner: Vec> = if params.ignore_white { + // Each hole belongs to the smallest outline that contains it, and to no other. (Holes are + // cut where white does not paint over them: with Ignore White, or with no white class.) + let owner: Vec> = if params.ignore_white || !own_white { holes .iter() .map(|hole| { diff --git a/crates/trace/src/logo/tests.rs b/crates/trace/src/logo/tests.rs index 2335fe77e..8d3f6a79f 100644 --- a/crates/trace/src/logo/tests.rs +++ b/crates/trace/src/logo/tests.rs @@ -62,6 +62,9 @@ fn render(w: u32, h: u32, blur: f64, shade: impl Fn(f64, f64) -> Option<[u8; 3]> const RED: [u8; 3] = [210, 40, 50]; const BLUE: [u8; 3] = [40, 80, 200]; +const LIGHT: [u8; 3] = [240, 240, 220]; +const SKY: [u8; 3] = [40, 120, 240]; +const WHITE: [u8; 3] = [255, 255, 255]; fn logo_params() -> TraceParams { TraceParams { @@ -205,6 +208,99 @@ fn the_named_palette_is_used_exactly_and_a_bad_one_is_an_error() { assert!(matches!(trace_within(&img, &bad, 1000), Err(TraceError::BadPalette(s)) if s == "red")); } +/// The color of the topmost path at `p` (paths are listed bottom first). +fn top_color(res: &TraceResult, p: Point) -> Option<[u8; 3]> { + res.paths.iter().rev().find(|t| t.path.to_bezpath().winding(p) != 0).map(|t| t.color) +} + +#[test] +fn logo_white_off_traces_only_the_named_colors() { + // A light disc in a black ring, a blue square on it, and two white spots. + let img = render(120, 120, 0.7, |x, y| { + let d = (x - 60.0).hypot(y - 60.0); + if (x - 32.0).hypot(y - 40.0) <= 6.0 || (x - 78.0).hypot(y - 86.0) <= 6.0 { + Some(WHITE) + } else if (45.0..=75.0).contains(&x) && (45.0..=75.0).contains(&y) { + Some(SKY) + } else if d <= 46.0 { + Some(LIGHT) + } else if d <= 50.0 { + Some([0, 0, 0]) + } else { + None + } + }); + let named = TraceParams { logo_colors: vec!["#000000".into(), "#F0F0DC".into(), "#2878F0".into()], ..logo_params() }; + // By default white is a fourth color. + let res = trace(&img, &named); + assert_eq!(res.palette.len(), 4, "{:?}", res.palette); + assert_eq!(top_color(&res, Point::new(32.0, 40.0)), Some(WHITE)); + // Without it, the three named colors only: the white spots take the light color. + let res = trace(&img, &TraceParams { logo_white: false, ..named.clone() }); + assert_eq!(res.palette.len(), 3, "{:?}", res.palette); + assert!(!res.palette.contains(&WHITE)); + for p in [Point::new(32.0, 40.0), Point::new(78.0, 86.0), Point::new(20.0, 60.0)] { + assert_eq!(top_color(&res, p), Some(LIGHT), "at {p:?}"); + } + assert_eq!(top_color(&res, Point::new(60.0, 60.0)), Some(SKY)); + assert_eq!(top_color(&res, Point::new(60.0, 12.0)), Some([0, 0, 0])); + assert!(!covered(&res, Point::new(5.0, 5.0))); + // Ignore White still leaves the white spots out. + let res = trace(&img, &TraceParams { logo_white: false, ignore_white: true, ..named.clone() }); + for p in [Point::new(32.0, 40.0), Point::new(78.0, 86.0)] { + assert!(!covered(&res, p), "at {p:?}"); + } + assert_eq!(top_color(&res, Point::new(20.0, 60.0)), Some(LIGHT)); +} + +#[test] +fn logo_white_off_keeps_a_named_white_and_knockouts_as_holes() { + let disc = render(64, 64, 0.7, |x, y| ((x - 32.0).hypot(y - 32.0) <= 20.0).then_some(WHITE)); + let only_white = TraceParams { logo_colors: vec!["#ffffff".into()], logo_white: false, ..logo_params() }; + let res = trace(&disc, &only_white); + assert_eq!(res.paths.len(), 1); + assert_eq!(res.paths[0].color, WHITE); + // An enclosed transparent area stays a hole: there is no white to paint it with. + let ring = render(64, 64, 0.7, |x, y| (12.0..=25.0).contains(&(x - 32.0).hypot(y - 32.0)).then_some(BLUE)); + let res = trace(&ring, &TraceParams { logo_colors: vec!["#2850c8".into()], logo_white: false, ..logo_params() }); + assert_eq!(res.paths.len(), 1); + assert_eq!(res.paths[0].path.subpaths.len(), 2, "outer outline and a hole"); + assert!(!covered(&res, Point::new(32.0, 32.0)), "the hole must be empty"); + assert!(covered(&res, Point::new(32.0, 32.0 + 18.0)), "the ring itself is filled"); +} + +#[test] +fn logo_white_off_gives_each_white_the_nearest_named_color() { + // Red and blue are equally far from white: the white disc takes the first one named. + let disc = render(64, 64, 0.7, |x, y| ((x - 32.0).hypot(y - 32.0) <= 20.0).then_some(WHITE)); + let params = TraceParams { logo_colors: vec!["#ff0000".into(), "#0000ff".into()], logo_white: false, ..logo_params() }; + let res = trace(&disc, ¶ms); + assert_eq!(top_color(&res, Point::new(32.0, 32.0)), Some([255, 0, 0])); + assert!(!covered(&res, Point::new(5.0, 5.0))); + // Each white takes the named color nearest it, so two named whites both stay. + const OFF: [u8; 3] = [245, 245, 245]; + let two = render(96, 64, 0.7, |x, y| { + if (x - 24.0).hypot(y - 32.0) <= 16.0 { + Some(OFF) + } else if (x - 72.0).hypot(y - 32.0) <= 16.0 { + Some(WHITE) + } else { + None + } + }); + let params = TraceParams { logo_colors: vec!["#f5f5f5".into(), "#ffffff".into()], logo_white: false, ..logo_params() }; + let res = trace(&two, ¶ms); + assert_eq!(top_color(&res, Point::new(24.0, 32.0)), Some(OFF)); + assert_eq!(top_color(&res, Point::new(72.0, 32.0)), Some(WHITE)); + // A white square on blue keeps its edges where they are when it turns red. + let square = render(100, 100, 0.7, |x, y| Some(if (30.0..70.0).contains(&x) && (30.0..70.0).contains(&y) { WHITE } else { [0, 0, 255] })); + let res = trace(&square, &TraceParams { logo_colors: vec!["#ff0000".into(), "#0000ff".into()], logo_white: false, ..logo_params() }); + for (inside, outside) in [(30.3, 29.7), (69.7, 70.3)] { + assert_eq!(top_color(&res, Point::new(inside, 50.0)), Some([255, 0, 0]), "at x {inside}"); + assert_eq!(top_color(&res, Point::new(outside, 50.0)), Some([0, 0, 255]), "at x {outside}"); + } +} + #[test] fn a_large_image_is_traced_at_reduced_size_and_scaled_back() { let (cx, cy, r) = (1200.0, 600.0, 400.0); diff --git a/crates/trace/src/params.rs b/crates/trace/src/params.rs index dd5bc9929..aed7984d8 100644 --- a/crates/trace/src/params.rs +++ b/crates/trace/src/params.rs @@ -91,6 +91,15 @@ pub struct TraceParams { /// The colour Ignore Color leaves out (`#rrggbb`): the palette colours within about 30 levels /// of it. `None` is white. (Flat Logo mode leaves out white only.) pub ignore_color: Option, + /// With Ignore Color: leave out only the areas of that color that reach the image's edge (a + /// background), through neighbors above, below and to either side, and trace the areas of it + /// that other colors enclose. (Flat Logo mode leaves out every white area.) + pub ignore_background: bool, + /// Keep Colors: the colors to trace (`#rrggbb`). Only the palette colors within about 30 + /// levels of one of them (as Ignore Color matches a color) are traced, a gradient by its middle + /// color, and their paths are the ones the trace of every color has. Empty traces every color. + /// (Flat Logo mode does not use it: it traces the colors named in `logo_colors`.) + pub keep_colors: Vec, /// Replace nearly-straight curves with straight lines. In Logo mode this also snaps tight arcs /// to true circles. pub snap_curves_to_lines: bool, @@ -110,9 +119,16 @@ pub struct TraceParams { /// Stroke: the widest feature (px) traced as a stroke. pub stroke_width: f64, /// Flat Logo mode: the flat colours (`#rrggbb`) to use; empty finds them automatically. White is - /// always traced on its own and cannot be named, colours closer than about 12 (RGB distance) - /// count as one, and at most ten are used. (Not `palette`: that is Color mode's colour source.) + /// traced on its own and cannot be named unless [`logo_white`](Self::logo_white) is off, colours + /// closer than about 12 (RGB distance) count as one, and at most ten are used. (Not `palette`: + /// that is Color mode's colour source.) pub logo_colors: Vec, + /// Flat Logo mode with named `logo_colors`: `true` traces white (every channel 245 or more) as a + /// color of its own beside the named ones. `false` traces the named colors only: white areas + /// take the nearest of them, a named white or near-white is used as named, and enclosed + /// transparent areas stay holes. With the colors found automatically, white is always its own, + /// and with [`ignore_white`](Self::ignore_white) white areas are left out whatever this says. + pub logo_white: bool, } impl Default for TraceParams { @@ -133,6 +149,8 @@ impl Default for TraceParams { method: Method::Abutting, ignore_white: false, ignore_color: None, + ignore_background: false, + keep_colors: Vec::new(), snap_curves_to_lines: false, shapes: false, auto_grouping: false, @@ -141,6 +159,7 @@ impl Default for TraceParams { strokes: false, stroke_width: 10.0, logo_colors: Vec::new(), + logo_white: true, } } } diff --git a/crates/trace/src/pipeline.rs b/crates/trace/src/pipeline.rs index 486eb9c0a..aa5c7e25b 100644 --- a/crates/trace/src/pipeline.rs +++ b/crates/trace/src/pipeline.rs @@ -36,6 +36,12 @@ pub struct TraceResult { /// option). Each path drawn together with those inside it, in the order they have here, paints /// what `paths` paints in its order. pub parents: Vec>, + /// Keep Colors: the colors listed in [`TraceParams::keep_colors`] (as written) that no color + /// the trace found matches, so that nothing of them is traced. (Flat Logo mode: empty.) + pub keep_unmatched: Vec, + /// Keep Colors: whether Ignore Color left out every color the trace found that a listed color + /// matches, so that none of them was traced. (Flat Logo mode: false.) + pub keep_ignored: bool, /// Flat Logo: the size in pixels the image was traced at when it was averaged down first; /// `None` when it was traced at its own size. pub worked_at: Option<(u32, u32)>, @@ -59,6 +65,92 @@ pub(crate) fn is_ignored(c: [u8; 3], target: Option<[u8; 3]>) -> bool { } } +/// Whether Keep Colors traces palette color `c`: every color when `keep` is empty, else those +/// Ignore Color would match to one of `keep`. +fn is_kept(c: [u8; 3], keep: &[[u8; 3]]) -> bool { + keep.is_empty() || keep.iter().any(|&k| is_ignored(c, Some(k))) +} + +/// For each color of `palette`, whether it is left out of every area: Ignore Color's when it +/// leaves out the whole color (`ignore`, `None` for white) and those Keep Colors does not list +/// (`keep`). +pub(crate) fn left_out(palette: &[[u8; 3]], params: &TraceParams, ignore: Option<[u8; 3]>, keep: &[[u8; 3]]) -> Vec { + palette.iter().map(|&c| (drops_color(params) && is_ignored(c, ignore)) || !is_kept(c, keep)).collect() +} + +/// Whether Ignore Color leaves out every area of its color (not only the background). +pub(crate) fn drops_color(params: &TraceParams) -> bool { + params.ignore_white && !params.ignore_background +} + +/// Ignore Color, background only: clears to [`TRANSPARENT`] every area of a label `ignored` leaves +/// out that reaches the image's edge, through neighbors above, below and to either side. It fills +/// one run of a row at a time, and fills from one edge pixel at a time. +pub(crate) fn clear_background(labels: &mut [u16], w: usize, h: usize, ignored: &[bool]) { + let (last_row, last_col) = (h.saturating_sub(1) * w, w.saturating_sub(1)); + let mut seeds = Vec::new(); + for edge in (0..w).flat_map(|x| [x, last_row + x]).chain((0..h).flat_map(|y| [y * w, y * w + last_col])) { + seeds.push(edge); + fill_left_out(labels, w, h, ignored, &mut seeds); + } +} + +/// Clears to [`TRANSPARENT`] the left-out areas that `seeds` reach, and empties `seeds`. +fn fill_left_out(labels: &mut [u16], w: usize, h: usize, ignored: &[bool], seeds: &mut Vec) { + let left_out = |labels: &[u16], i: usize| labels.get(i).is_some_and(|&l| ignored.get(l as usize).copied().unwrap_or(false)); + while let Some(i) = seeds.pop() { + if !left_out(labels, i) { + continue; + } + // The run of left-out pixels along the row through `i`. + let row = i - i % w; + let (mut a, mut b) = (i, i + 1); + while a > row && left_out(labels, a - 1) { + a -= 1; + } + while b < row + w && left_out(labels, b) { + b += 1; + } + if let Some(run) = labels.get_mut(a..b) { + run.fill(TRANSPARENT); + } + // A seed at the start of each run of left-out pixels next to it in the rows above and below. + for next in [row.checked_sub(w), Some(row + w).filter(|&r| r < w * h)].into_iter().flatten() { + let mut inside = false; + for x in a - row..b - row { + let here = left_out(labels, next + x); + if here && !inside { + seeds.push(next + x); + } + inside = here; + } + } + } +} + +/// A box of pixels: `(x0, y0, x1, y1)`, the far sides excluded. +type PixelBox = (usize, usize, usize, usize); + +/// Each of `n` palette entries' pixel count in `labels`, and the box its pixels lie in within +/// `traced` (`labels` with Ignore Color's background [`TRANSPARENT`]). A layer is traced within its +/// box, so a color whose background is left out is traced within the box of its remaining areas. +pub(crate) fn label_extents(labels: &[u16], traced: &[u16], w: usize, n: usize) -> (Vec, Vec>) { + let mut counts = vec![0usize; n]; + for &l in labels { + if let Some(c) = counts.get_mut(l as usize) { + *c += 1; + } + } + let mut boxes: Vec> = vec![None; n]; + for (i, &l) in traced.iter().enumerate() { + if let Some(b) = boxes.get_mut(l as usize) { + let (x, y) = (i % w, i / w); + *b = Some(b.map_or((x, y, x + 1, y + 1), |b| (b.0.min(x), b.1.min(y), b.2.max(x + 1), b.3.max(y + 1)))); + } + } + (counts, boxes) +} + /// Trace `img` with `params`. Failures (an image Logo mode refuses, say) give an empty result: use /// [`trace_within`] to see why. pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult { @@ -98,6 +190,12 @@ pub fn trace_watched( fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize, steps: &(dyn Fn(u64, u64) -> bool + Sync)) -> Result { let (w, h) = (img.width as usize, img.height as usize); + // The colors Keep Colors traces (checked in every mode). + let listed = params + .keep_colors + .iter() + .map(|name| logo::parse_hex(name).ok_or_else(|| TraceError::BadPalette(name.clone()))) + .collect::, _>>()?; // After each stage before the colour layers, whose number isn't known yet. let go_on = || if steps(0, 0) { Ok(()) } else { Err(TraceError::Cancelled) }; if params.mode == Mode::Logo { @@ -160,16 +258,23 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize, steps: &( let keep: Vec = q.palette.iter().map(|c| rare.contains(c)).collect(); quantize::denoise_keeping(&mut q.labels, w, h, params.noise as usize, &keep); go_on()?; - // Pixel count per palette entry, and the box its pixels lie in. - let mut counts = vec![0usize; q.palette.len()]; - let mut boxes: Vec> = vec![None; q.palette.len()]; - for (i, &l) in q.labels.iter().enumerate() { - if let (Some(c), Some(b)) = (counts.get_mut(l as usize), boxes.get_mut(l as usize)) { - *c += 1; - let (x, y) = (i % w, i / w); - *b = Some(b.map_or((x, y, x + 1, y + 1), |b| (b.0.min(x), b.1.min(y), b.2.max(x + 1), b.3.max(y + 1)))); + // Ignore Color, background only: the labels traced, with the ignored color's areas that reach + // the edge left out. Its other areas are traced with the rest. Edges are still placed by + // `q.labels`, as when the whole color is left out. + let cleared = (params.ignore_white && params.ignore_background).then(|| { + let ignored: Vec = q.palette.iter().map(|&c| is_ignored(c, ignore)).collect(); + let mut labels = q.labels.clone(); + clear_background(&mut labels, w, h, &ignored); + labels + }); + // Whether any of a palette entry's pixels are left after the background. + let mut remains = vec![false; q.palette.len()]; + for &l in cleared.iter().flatten() { + if let Some(r) = remains.get_mut(l as usize) { + *r = true; } } + let (counts, boxes) = label_extents(&q.labels, cleared.as_deref().unwrap_or(&q.labels), w, q.palette.len()); // Layers bottom → top: largest area first. let mut order: Vec = (0..q.palette.len()).filter(|&i| counts[i] > 0).collect(); order.sort_by(|a, b| counts[*b].cmp(&counts[*a]).then(a.cmp(b))); @@ -191,14 +296,33 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize, steps: &( for (r, &i) in order.iter().enumerate() { rank[i] = r; } - let layers: Vec<(usize, usize)> = - order.iter().copied().enumerate().filter(|&(_, ci)| !(params.ignore_white && is_ignored(q.palette[ci], ignore))).collect(); + // Layers left out: Ignore Color's (all of a color's areas, unless only its background is left + // out and some area remains), and those Keep Colors does not list. Abutting, the others are + // traced as they are in the trace of every color; stacked, they do not reach under the colors + // left out (see `Layers::ignored`). + let left_out = left_out(&q.palette, params, ignore, &listed); + let dropped = |ci: usize| { + (params.ignore_white && is_ignored(q.palette[ci], ignore) && !remains.get(ci).copied().unwrap_or(false)) + || left_out.get(ci).copied().unwrap_or(false) + }; + let layers: Vec<(usize, usize)> = order.iter().copied().enumerate().filter(|&(_, ci)| !dropped(ci)).collect(); + // The listed colors that match none of the colors found. + let keep_unmatched = params + .keep_colors + .iter() + .zip(&listed) + .filter(|&(_, &k)| !order.iter().any(|&ci| is_kept(q.palette[ci], &[k]))) + .map(|(name, _)| name.clone()) + .collect(); + // Every layer is left out although a listed color matches one: Ignore Color left those out. + let keep_ignored = !listed.is_empty() && layers.is_empty() && order.iter().any(|&ci| is_kept(q.palette[ci], &listed)); let job = Layers { q: &q, + traced: cleared.as_deref().unwrap_or(&q.labels), rank: &rank, params, img, - ignore, + left_out, boxes, gradients: &gradients, bw_tones: bw_tones(img, &q), @@ -208,7 +332,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize, steps: &( anchors: AtomicUsize::new(0), max_anchors, }; - let mut out = TraceResult::default(); + let mut out = TraceResult { keep_unmatched, keep_ignored, ..TraceResult::default() }; // A layer takes about ten bytes a pixel of its box while it is traced (its mask, the labels // of its components, the stack that fills them, its edges): as many layers at a time as fit // in half a gigabyte, so that a large image is not traced at the cost of gigabytes. diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 93467b352..19f208175 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1444,6 +1444,231 @@ fn ignore_color_leaves_out_the_colour_picked() { assert_eq!(serde_json::to_value(¶ms).unwrap()["ignoreColor"], serde_json::Value::Null); } +/// A navy field with a yellow disc, a green bar and a red square on it, hard-edged. +fn four_color_art() -> Raster { + Raster::from_fn(160, 120, |x, y| { + let (fx, fy) = (f64::from(x) + 0.5, f64::from(y) + 0.5); + if Point::new(fx, fy).distance(Point::new(50.0, 45.0)) <= 30.0 { + [250, 200, 40, 255] + } else if (20.0..140.0).contains(&fx) && (90.0..108.0).contains(&fy) { + [40, 160, 90, 255] + } else if (100.0..140.0).contains(&fx) && (20.0..60.0).contains(&fy) { + [200, 40, 50, 255] + } else { + [20, 40, 90, 255] + } + }) +} + +/// A 600 × 400 field of `edge` with a light panel (40..560 × 40..360) and, on the panel, a ring of +/// `line` 8 px wide and 100 px in radius around (300, 200), filled with `inside`. +fn panel_with_ring(edge: [u8; 4], line: [u8; 4], inside: [u8; 4]) -> Raster { + Raster::from_fn(600, 400, |x, y| { + let (fx, fy) = (f64::from(x) + 0.5, f64::from(y) + 0.5); + let r = Point::new(fx, fy).distance(Point::new(300.0, 200.0)); + if !((40.0..560.0).contains(&fx) && (40.0..360.0).contains(&fy)) { + edge + } else if (96.0..=104.0).contains(&r) { + line + } else if r < 96.0 { + inside + } else { + [240, 240, 220, 255] + } + }) +} + +#[test] +fn keep_colors_traces_only_the_colors_listed() { + let near = |c: [u8; 3], want: [u8; 3]| (0..3).all(|k| c[k].abs_diff(want[k]) <= 3); + let (yellow, red, navy) = ([250, 200, 40], [200, 40, 50], [20, 40, 90]); + let img = four_color_art(); + let pixels = |res: &TraceResult, c: [u8; 3]| res.paths.iter().filter(|p| near(p.color, c)).map(|p| p.pixels).sum::(); + let abutting = TraceParams { mode: Mode::Color, palette: Palette::Limited, colors: 4, ..TraceParams::default() }; + let abutting_all = trace(&img, &abutting); + for method in [Method::Abutting, Method::Overlapping] { + let params = TraceParams { method, ..abutting.clone() }; + let all = trace(&img, ¶ms); + assert_eq!(all.palette.len(), 4, "{method:?}: {:?}", all.palette); + // The yellow and the red, each picked a little off. + let res = trace(&img, &TraceParams { keep_colors: vec!["#f0c832".into(), "#c8283c".into()], ..params.clone() }); + assert_eq!(res.palette.len(), 2, "{method:?}: {:?}", res.palette); + if method == Method::Abutting { + // Their paths as the whole trace has them. + let want: Vec = all.paths.iter().filter(|p| near(p.color, yellow) || near(p.color, red)).cloned().collect(); + assert!(want.iter().any(|p| near(p.color, yellow)) && want.iter().any(|p| near(p.color, red))); + assert_eq!(res.paths, want); + } else { + // Stacked, each kept color covers what it covers in the trace of every color: the red + // square touches neither the yellow disc nor the green left out, so it covers only itself. + assert!(res.paths.iter().all(|p| near(p.color, yellow) || near(p.color, red))); + assert_eq!(pixels(&res, red), pixels(&all, red)); + assert_eq!(pixels(&res, red), pixels(&abutting_all, red)); + assert_eq!(pixels(&res, yellow), pixels(&abutting_all, yellow)); + // The navy field alone, at the bottom of the stack: no part of the colors left out. + let field = trace(&img, &TraceParams { keep_colors: vec!["#14285a".into()], ..params.clone() }); + assert!(!field.paths.is_empty()); + assert_eq!(pixels(&field, navy), pixels(&abutting_all, navy)); + assert!(pixels(&all, navy) > pixels(&abutting_all, navy)); + } + // Every color listed: the trace of every color, as with none listed. + let every = vec!["#fac828".into(), "#28a05a".into(), "#c82832".into(), "#14285a".into()]; + assert_eq!(trace(&img, &TraceParams { keep_colors: every, ..params.clone() }), all, "{method:?}"); + // A color the image does not have: nothing is traced. + assert!(trace(&img, &TraceParams { keep_colors: vec!["#ff00ff".into()], ..params.clone() }).paths.is_empty(), "{method:?}"); + // With Ignore Color as well, a kept color it matches is left out. + let both = TraceParams { + keep_colors: vec!["#fac828".into(), "#14285a".into()], + ignore_white: true, + ignore_color: Some("#14285a".into()), + ..params.clone() + }; + let res = trace(&img, &both); + assert!(!res.paths.is_empty() && res.paths.iter().all(|p| near(p.color, yellow)), "{method:?}"); + } + // A color that is none is refused, in Flat Logo mode too. + for mode in [Mode::Color, Mode::Logo] { + let bad = trace_within(&img, &TraceParams { mode, keep_colors: vec!["#c8283c".into(), "red".into()], ..TraceParams::default() }, usize::MAX); + assert!(matches!(&bad, Err(TraceError::BadPalette(name)) if name == "red"), "{mode:?}"); + } + // Old documents have no such setting: every color. + let old: TraceParams = serde_json::from_str(r#"{"mode":"color"}"#).unwrap(); + assert!(old.keep_colors.is_empty()); + let on: TraceParams = serde_json::from_str(r##"{"keepColors":["#c8283c"]}"##).unwrap(); + assert_eq!(on.keep_colors, ["#c8283c"]); +} + +#[test] +fn keep_colors_names_the_colors_nothing_matches() { + let img = four_color_art(); + let params = TraceParams { mode: Mode::Color, palette: Palette::Limited, colors: 4, ..TraceParams::default() }; + let unmatched = |keep: &[&str], params: &TraceParams| { + let res = trace(&img, &TraceParams { keep_colors: keep.iter().map(|k| k.to_string()).collect(), ..params.clone() }); + (res.keep_unmatched, res.paths.len()) + }; + // Every color listed matches one the trace found. + assert_eq!(unmatched(&["#f0c832", "#c8283c"], ¶ms).0, Vec::::new()); + // A color the image does not have is named, as written, beside the ones traced. + let (names, paths) = unmatched(&["#f0c832", "#FF00FF", "#c8283c"], ¶ms); + assert_eq!(names, ["#FF00FF"]); + assert!(paths > 0); + // None matches: every one is named, and nothing is traced. + assert_eq!(unmatched(&["#ff00ff", "#00ffff"], ¶ms), (vec!["#ff00ff".to_string(), "#00ffff".to_string()], 0)); + // A color Ignore Color leaves out still matches one the trace found. + let ignoring = TraceParams { ignore_white: true, ignore_color: Some("#14285a".into()), ..params.clone() }; + assert_eq!(unmatched(&["#14285a"], &ignoring), (Vec::new(), 0)); + // The result says that Ignore Color left out every color that matches, and only then. + let keep = |keep: &[&str], params: &TraceParams| { + trace(&img, &TraceParams { keep_colors: keep.iter().map(|k| k.to_string()).collect(), ..params.clone() }) + }; + assert!(keep(&["#14285a"], &ignoring).keep_ignored); + assert!(!keep(&["#14285a", "#f0c832"], &ignoring).keep_ignored); + assert!(!keep(&["#14285a"], ¶ms).keep_ignored); + assert!(!keep(&["#ff00ff"], &ignoring).keep_ignored); + // With none listed, none is named. + assert!(trace(&img, ¶ms).keep_unmatched.is_empty()); + // Flat Logo mode does not use the list. + assert!(trace(&img, &TraceParams { mode: Mode::Logo, keep_colors: vec!["#ff00ff".into()], ..params }).keep_unmatched.is_empty()); +} + +#[test] +fn ignore_background_leaves_out_only_the_areas_of_the_color_that_reach_the_edge() { + use kurbo::Shape; + let covers = |res: &TraceResult, color: [u8; 3], x: f64, y: f64| { + res.paths.iter().any(|t| t.color == color && t.path.to_bezpath().winding(kurbo::Point::new(x, y)) != 0) + }; + let covered = |res: &TraceResult, x: f64, y: f64| res.paths.iter().any(|t| t.path.to_bezpath().winding(kurbo::Point::new(x, y)) != 0); + // A black field, a light panel and a black ring on it. + let light = [240, 240, 220, 255]; + let img = panel_with_ring(BLACK, BLACK, light); + for method in [Method::Abutting, Method::Overlapping] { + let params = TraceParams { + mode: Mode::Color, + palette: Palette::Limited, + colors: 2, + method, + ignore_white: true, + ignore_color: Some("#000000".into()), + ..TraceParams::default() + }; + // The whole color: the ring goes with the field. + let all = trace(&img, ¶ms); + assert!(all.paths.iter().all(|p| p.color != [0, 0, 0]), "{method:?}"); + assert!(!covered(&all, 300.0, 100.0) && !covered(&all, 10.0, 10.0), "{method:?}"); + // The background only: the field goes, the ring stays. + let res = trace(&img, &TraceParams { ignore_background: true, ..params.clone() }); + assert!(covers(&res, [0, 0, 0], 300.0, 100.0) && covers(&res, [0, 0, 0], 300.0, 300.0), "{method:?}"); + assert!(covers(&res, [240, 240, 220], 100.0, 200.0) && covers(&res, [240, 240, 220], 300.0, 200.0), "{method:?}"); + assert!(!covered(&res, 10.0, 10.0) && !covered(&res, 590.0, 390.0) && !covered(&res, 300.0, 20.0), "{method:?}"); + // Without Ignore Color, the setting has no effect. + let kept = |p: &TraceParams| trace(&img, &TraceParams { ignore_white: false, ..p.clone() }); + assert_eq!(kept(&TraceParams { ignore_background: true, ..params.clone() }), kept(¶ms), "{method:?}"); + } + // White, the default: a white field goes, the white inside a ring stays. + let img = panel_with_ring(WHITE, BLACK, WHITE); + let params = TraceParams { mode: Mode::Color, colors: 3, ignore_white: true, ignore_background: true, ..TraceParams::default() }; + let res = trace(&img, ¶ms); + assert!(covers(&res, [255, 255, 255], 300.0, 200.0) && !covered(&res, 10.0, 10.0)); + assert!(!covered(&trace(&img, &TraceParams { ignore_background: false, ..params }), 300.0, 200.0)); + // With no area of the color enclosed, the trace is the one that leaves out the whole color, + // its anti-aliased edges placed the same. + let img = three_colour_art(0.0); + for method in [Method::Abutting, Method::Overlapping] { + let params = TraceParams { colors: 3, method, ignore_white: true, ignore_color: Some("#14285a".into()), ..sixteen_colors() }; + assert_eq!(trace(&img, &TraceParams { ignore_background: true, ..params.clone() }), trace(&img, ¶ms), "{method:?}"); + } + // Old documents have no such setting: the whole color. + let old: TraceParams = serde_json::from_str(r#"{"mode":"color","ignoreWhite":true}"#).unwrap(); + assert!(!old.ignore_background); + let on: TraceParams = serde_json::from_str(r#"{"ignoreWhite":true,"ignoreBackground":true}"#).unwrap(); + assert!(on.ignore_background); +} + +#[test] +fn ignore_background_traces_a_color_within_the_box_of_its_remaining_areas() { + // A 40 × 30 field of color 0 with a ring of color 1 around a 2 × 2 area of color 0. + let (w, h) = (40, 30); + let mut labels = vec![0u16; w * h]; + for y in 10..16 { + for x in 20..26 { + labels[y * w + x] = 1; + } + } + for y in 12..14 { + for x in 22..24 { + labels[y * w + x] = 0; + } + } + let mut cleared = labels.clone(); + clear_background(&mut cleared, w, h, &[true, false]); + let (counts, boxes) = label_extents(&labels, &cleared, w, 2); + // The counts stay those of the whole image (they order the layers); the boxes are those of + // what is traced. + assert_eq!(counts, vec![w * h - 32, 32]); + assert_eq!(boxes, vec![Some((22, 12, 24, 14)), Some((20, 10, 26, 16))]); + // Without Ignore Color, the boxes are those of every pixel. + assert_eq!(label_extents(&labels, &labels, w, 2).1, vec![Some((0, 0, w, h)), Some((20, 10, 26, 16))]); +} + +#[test] +fn ignore_background_clears_a_tall_narrow_image_from_its_edges() { + // A 1 × 200,000 column of color 0 with color 1 at rows 1,000 to 1,009: every pixel is on the + // edge, so all of color 0 is background. + let (w, h) = (1, 200_000); + let mut labels = vec![0u16; w * h]; + for l in &mut labels[1_000..1_010] { + *l = 1; + } + clear_background(&mut labels, w, h, &[true, false]); + assert_eq!(labels.iter().filter(|&&l| l == TRANSPARENT).count(), h - 10); + assert!(labels[1_000..1_010].iter().all(|&l| l == 1)); + // In a 2 × 100,000 column of color 0, every pixel is on the edge too. + let (w, h) = (2, 100_000); + let mut labels = vec![0u16; w * h]; + clear_background(&mut labels, w, h, &[true, false]); + assert!(labels.iter().all(|&l| l == TRANSPARENT)); +} + #[test] fn snap_curves_to_lines_levels_a_rectangle_a_degree_askew() { // 120 × 60, turned 1.5° about its centre. diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index a74582972..d02c59728 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -89,6 +89,7 @@ impl VectorcraftApp { view_moved: None, hover_cursor: None, presented: None, + opening_fit: None, panes: Default::default(), windows: crate::doc_windows::Windows::new(known), }, @@ -109,6 +110,7 @@ impl VectorcraftApp { deferred_control: None, synthetic_dropped_before: 0, pending_screenshots: vec![], + pointer_replies: vec![], queued_screenshots: vec![], screenshot_token: 0, screenshot_reserved: 0, @@ -122,7 +124,9 @@ impl VectorcraftApp { frame_rate: Default::default(), canvas_rect: None, hover_doc: None, + titlebar_from_env: false, custom_titlebar: false, + desktop_frame: false, last_window_title: String::new(), fullscreen_for_mode: false, graphics_adapter: None, @@ -164,6 +168,7 @@ impl VectorcraftApp { scrub_scope: None, automation_roots: None, synthetic_frame: false, + pending_plugin_install: None, pen_end: Default::default(), host_iteration: 0, #[cfg(test)] @@ -334,8 +339,9 @@ impl VectorcraftApp { ctx.request_repaint_after(std::time::Duration::from_millis(100)); } // The window's close button (or the system quitting the app) asks about unsaved documents, - // once the saves running in the background are done. Not while a file dialog's sheet is on - // the window: what asked for it runs again when it answers. + // once the saves running in the background are done and a Shape Builder merge being + // computed is applied. Not while a file dialog's sheet is on the window: what asked for it + // runs again when it answers. if ctx.input(|i| i.viewport().close_requested()) { // This native viewport is going away: release its IME context. No request is // allocated here: the close prompt or file sheet publishes no custom descriptor, so a @@ -343,7 +349,7 @@ impl VectorcraftApp { // fresh generation on the next pass (never inherits a composition across the prompt). crate::ime::close_viewport(self, ctx); self.ime_context.end_viewport(ctx.viewport_id()); - background::wait_all(self); + unsaved::finish_pending(self); if self.file_sheet_open() { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); self.status(picks::busy()); @@ -363,6 +369,7 @@ impl VectorcraftApp { } crate::keymap::note_os(ctx); shortcut_editor::sync(&self.ui); + crate::dialogs::svg_filter::sync(self); crate::panels::actions::sync_function_keys(&self.ui); prefs_dialog::apply_runtime(self, ctx); // The OS title bar (and the taskbar / Alt-Tab entry) follows the active file; with the @@ -370,6 +377,7 @@ impl VectorcraftApp { crate::chrome::sync_window_title(self, ctx); crate::chrome::sync_fullscreen(self, ctx); self.drain_control(ctx); + self.answer_pointer_replies(ctx); // An egui `PasteImage` with a rich system clipboard: run the paste command now, once. It // goes through `run`, whose `edit.paste` branch calls `adopt_system_clipboard` (which clears // the request) to read the formats and then pastes them. A text paste in the same UI pass @@ -453,7 +461,8 @@ impl VectorcraftApp { /// in a floating window, the tab strip's document draws as a pane. pub(crate) fn canvas_area(&mut self, ui: &mut egui::Ui) { let t = theme::Tokens::get(ui.ctx()); - ui.painter().rect_filled(ui.max_rect(), 0.0, t.pasteboard); + // Tools may already occupy the left side of this panel. + ui.painter().rect_filled(ui.available_rect_before_wrap(), 0.0, t.pasteboard); self.canvas.windows.strip = None; if self.ui.screen_mode < 3 { chrome::doc_tabs(self, ui); @@ -534,15 +543,29 @@ impl VectorcraftApp { } if self.ui.status_bar && self.ui.screen_mode < 3 { chrome::status_bar(self, ui); + } + if self.ui.help_bar && self.ui.screen_mode < 3 { chrome::hint_bar(self, ui); - } else if !(self.ui.control_bar && self.ui.control_bar_bottom && self.ui.screen_mode < 2) { + } + if (self.ui.screen_mode >= 3 || (!self.ui.status_bar && !self.ui.help_bar)) + && !(self.ui.control_bar && self.ui.control_bar_bottom && self.ui.screen_mode < 2) + { // Keeps the previous frame's value valid for menus drawn above, before the bars. A // Control bar docked at the bottom has set it to the bar's top. widgets::set_menu_floor(&ctx, ctx.content_rect().bottom()); } if self.ui.toolbar && self.ui.screen_mode < 3 { + tools_float::set_window_dock_zone(&ctx, ui.available_rect_before_wrap()); + } + if !self.ui.tools_beside_canvas { + // A canvas-edge target left from before the setting was turned off docks nothing. + tools_float::clear_canvas_dock_zone(&ctx); + } + // Beside the canvas, docked Tools draws inside the canvas panel (`toolbar::show_beside_canvas`). + // So does Tools docked on the right, left of the panels. + if self.ui.toolbar && self.ui.screen_mode < 3 && ((!self.ui.tools_beside_canvas && !self.ui.toolbar_right) || self.ui.toolbar_pos.is_some()) { toolbar::show(self, ui); - } else if self.ui.screen_mode < 3 && self.ui.tab_hid.is_none() { + } else if !self.ui.toolbar && self.ui.screen_mode < 3 && self.ui.tab_hid.is_none() { // Window › Tools hides the Tools panel alone; Tab hides the toolbars made with New // Toolbar too. toolbar::show_custom(self, ui.ctx()); diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 985f98c34..d5ca8a196 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -1,5 +1,5 @@ use super::fonts::MAX_RECENT_FONTS; -use super::{Value, VectorcraftApp, dialogs, fileio, io, menus, panels, picks, toolbar}; +use super::{Value, VectorcraftApp, canvas, dialogs, fileio, io, menus, panels, picks, toolbar}; use crate::placing; use crate::state::ReplaceFontWarning; @@ -94,6 +94,17 @@ impl VectorcraftApp { result } + /// A command that waits for the edit the tool computes on another thread and the input held + /// for it waits before the app adds the view's active artboard to its params: a held press + /// may change it. The app's own commands (its dialogs, panels and view) wait too. + fn wait_for_tool_edit(&mut self, id: &str) { + let ui_command = vectorcraft_engine::find_command(id).is_none(); + if (ui_command || vectorcraft_engine::waits_for_tool_edit(id)) && self.session.tool_waiting() { + let r = self.session.finish_tool_edit(); + canvas::apply_requests(self, r); + } + } + /// [`Self::run`] it, inside what asks for file dialogs. fn run_now(&mut self, id: &str, params: Value) -> Result { crate::state::sync_zoom_stops_display_print_size(self.session.prefs.display_print_size); @@ -104,6 +115,7 @@ impl VectorcraftApp { if cfg!(debug_assertions) && !self.session.replaying() { menus::check_params(&self.session, id, ¶ms)?; } + self.wait_for_tool_edit(id); // A `.ai` file a command writes (a save, an export, a timestamped copy, a batch's save) names // the view's active artboard. io::remember_active_artboard(self); @@ -250,7 +262,7 @@ impl VectorcraftApp { let to = ¶ms["to"]; to["family"].as_str().and_then(|family| { let style = to["style"].as_str().unwrap_or("Regular"); - vectorcraft_text::FontDb::global().resolve(family, style).map(|(face, _)| face.family.clone()) + vectorcraft_text::FontDb::global().stored_family(family, style) }) } _ => None, diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 213204b95..5ef64dfa3 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -1,6 +1,6 @@ use super::{ MAX_PENDING_SCREENSHOTS, MAX_SCREENSHOT_BYTES, MAX_SCREENSHOT_TOTAL_BYTES, PendingScreenshot, QueuedScreenshot, SCREENSHOT_TIMEOUT_MS, - VectorcraftApp, file_access, json, now_ms, + VectorcraftApp, canvas, file_access, json, now_ms, }; use crate::control; @@ -112,6 +112,9 @@ impl VectorcraftApp { } } } + control::Outcome::AfterToolEdit { from, to, document } => { + self.pointer_replies.push(control::PendingPointerReply { from, to, document, reply }); + } } } if handled == control::MAX_CONTROL_REQUESTS_PER_FRAME { @@ -121,6 +124,38 @@ impl VectorcraftApp { self.control_rx = Some(rx); } + /// Sends the `ui.pointer` replies that wait for the tool's edit once their events have been + /// made and the edit is applied. The canvas checks on the tool each frame it is drawn; this + /// checks too, for frames without it. + pub(crate) fn answer_pointer_replies(&mut self, ctx: &egui::Context) { + // Those whose events the canvas's check made go first, before this check makes the input + // held after them. + self.send_pointer_replies(); + if self.pointer_replies.is_empty() { + return; + } + if self.session.tool_editing() { + let r = self.session.tool_poll(self.view_info(), false); + canvas::apply_requests(self, r); + } + self.send_pointer_replies(); + if !self.pointer_replies.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } + + /// Sends the waiting `ui.pointer` replies whose events have been made. + fn send_pointer_replies(&mut self) { + for pending in std::mem::take(&mut self.pointer_replies) { + let control::PendingPointerReply { from, to, document, reply } = pending; + if self.session.input_applied(from, to) { + let _ = reply.send(control::pointer_reply_for(self, document)); + } else { + self.pointer_replies.push(control::PendingPointerReply { from, to, document, reply }); + } + } + } + /// The active document's identity: its process-unique uid and revision. A capture records it /// at admission and issuance and refuses to answer with a different document or undo state. pub(super) fn document_identity(&self) -> Option<(u64, u64)> { diff --git a/crates/ui-egui/src/app/links.rs b/crates/ui-egui/src/app/links.rs index 7d9116290..8f4ce246f 100644 --- a/crates/ui-egui/src/app/links.rs +++ b/crates/ui-egui/src/app/links.rs @@ -24,12 +24,18 @@ impl VectorcraftApp { /// control or MCP request, or a frame of synthetic input): the automation roots don't confine /// what that app does. pub(crate) fn refuse_automation(&self, id: &str) -> Result<(), String> { - if self.ui.answering_control || self.synthetic_frame { + if self.automation_is_acting() { return Err(format!("{id} opens another app: only the person at the keyboard can run it")); } Ok(()) } + /// Did automation send what is being handled now: a control or MCP request, or a frame of + /// synthetic input? + pub(crate) fn automation_is_acting(&self) -> bool { + self.ui.answering_control || self.synthetic_frame + } + /// Run a Help link command and open the URL it returns. pub fn open_link(&mut self, id: &str) { if let Ok(v) = self.run(id, serde_json::json!({})) diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index 1bb731ca8..a9211eccf 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -65,6 +65,10 @@ pub struct CanvasCache { /// In Presentation Mode, the canvas rect the active artboard was last fitted to: it is fitted /// again when the rect changes (the window going full screen). pub presented: Option, + /// The canvas a document's opening view was fitted to, with the view the fit gave. The window's + /// layout can still move the canvas's edges over the next frames, so until the view is moved or + /// the user acts, the opening view is fitted again when the canvas changes ([`canvas::show`]). + pub opening_fit: Option, /// Window › Arrange › Tile: the renders the panes of the other documents draw. pub panes: canvas::Panes, /// Window › Arrange › Float in Window: what the floating document windows keep between frames. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0793ac9e0..19244247d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -186,6 +186,36 @@ fn view_rects(app: &VectorcraftApp, outer: egui::Rect, ppp: f32) -> (egui::Rect, (full, rect.round_to_pixels(ppp)) } +/// Whether the user pressed a button or a key, typed, scrolled or zoomed in this frame: the +/// opening fit stops following the canvas then ([`crate::caches::CanvasCache::opening_fit`]). +fn acted(i: &egui::InputState) -> bool { + i.pointer.any_pressed() + || i.events.iter().any(|e| { + matches!( + e, + egui::Event::Key { pressed: true, .. } + | egui::Event::Text(_) + | egui::Event::MouseWheel { .. } + | egui::Event::Zoom(_) + | egui::Event::Touch { .. } + ) + }) +} + +/// The view `v` shows in the canvas `rect`, as the opening fit records it. +fn view_key(v: &View, rect: egui::Rect) -> ViewKey { + ViewKey { rect, zoom: v.zoom, center: v.center, rotation: v.rotation } +} + +/// Whether the opening fit still follows the canvas `rect` (the view is unfitted, or unmoved since +/// the fit and the user hasn't acted), and whether the canvas moved under it this frame, so the view +/// is fitted again. +fn opening_fit_follows(app: &VectorcraftApp, ui: &Ui, rect: egui::Rect, fitted: bool) -> (bool, bool) { + let following = !fitted || (!ui.input(acted) && app.canvas.opening_fit.zip(app.view()).is_some_and(|(was, v)| view_key(v, was.rect) == was)); + let settling = fitted && following && app.canvas.opening_fit.is_some_and(|was| was.rect != rect); + (following, settling) +} + /// Paint the document canvas, complete or cached artwork, navigation and tool overlays. pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); @@ -225,13 +255,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let (full, rect) = view_rects(app, outer, ui.ctx().pixels_per_point()); app.canvas_rect = Some(rect); let fitted = app.view().is_some_and(|v| v.fitted); + // A document's opening view is fitted to the canvas of the first frame that shows it, and the + // window's layout can still move the canvas's edges over the next frames. Until the view is + // moved or the user acts, the opening fit follows the canvas, so that the document opens at the + // zoom of the settled window. + let (following, settling) = opening_fit_follows(app, ui, rect, fitted); // Presentation Mode shows the active artboard on the whole screen and nothing but the art: // no selection, guides, grids or artboard edges (#4365). let presenting = app.ui.screen_mode == 3; - if !fitted || (presenting && app.canvas.presented != Some(rect)) { + if !fitted || settling || (presenting && app.canvas.presented != Some(rect)) { fit(app, "view.fitArtboard"); } app.canvas.presented = presenting.then_some(rect); + app.canvas.opening_fit = following.then(|| app.view().map(|v| view_key(v, rect))).flatten(); // Before the canvas's own interaction, so the view the canvas draws is the one they moved to. if bars { scroll_bars(app, ui, rect, outer, &t); diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index 63b4ef9c9..930daf8fd 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -376,19 +376,29 @@ pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_e { v.artboard = i; } - // A press with a selection tool inside an artboard makes it the active one (#693), as the - // Artboard tool's does: Paste in Place and in Front or Back then paste onto it, Align to - // Artboard aligns to it, and All on Active Artboard selects on it. - if ev.kind == PointerKind::Down - && vectorcraft_tools::catalog::is_selection_tool(app.session.tool_id()) - && let Some(i) = app.session.active().and_then(|d| d.doc.artboard_at(ev.pos)) - && let Some(v) = app.view_mut() - { - v.artboard = i; + if ev.kind == PointerKind::Down { + pressed_with(app, app.session.active_index(), ev.pos, app.session.tool_id()); } apply_requests(app, r); } +/// A press at `pos` in document `index` with tool `tool` (a selection tool) inside an artboard +/// makes it that document's active one (#693), as the Artboard tool's does: Paste in Place and in +/// Front or Back then paste onto it, Align to Artboard aligns to it, and All on Active Artboard +/// selects on it. +fn pressed_with(app: &mut VectorcraftApp, index: Option, pos: Point, tool: &str) { + if !vectorcraft_tools::catalog::is_selection_tool(tool) { + return; + } + app.sync_views(); + if let Some(i) = index + && let Some(board) = app.session.documents().get(i).and_then(|d| d.doc.artboard_at(pos)) + && let Some(v) = app.views.get_mut(i) + { + v.artboard = board; + } +} + /// Act on what a tool event asked the UI for (dialogs, tool switches), or show its error. pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result>) { match r { @@ -399,6 +409,15 @@ pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result app.select_tool(&t), vectorcraft_engine::UiRequest::Status(msg) => app.status(msg), vectorcraft_engine::UiRequest::Place(p) => crate::place::place_from_cursor(app, &p), + // In the document the press was made in, which may no longer be the active one. + vectorcraft_engine::UiRequest::HeldPress { doc, pos, tool } => { + let index = doc.and_then(|uid| app.session.documents().iter().position(|d| d.uid == uid)); + pressed_with(app, index, pos, tool); + // The selection tool Cmd lent has the press now, so its drag can carry art to another tab. + if let Some(ctx) = app.ui_ctx.clone() { + crate::document_drag::begin(app, &ctx, pos); + } + } } } } diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 49baf494b..5fbc2b903 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -147,12 +147,12 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo app.session.tool_hover_ended(view); ui.ctx().request_repaint(); } - // The tool works on another thread (a large selection's planar map): it checks each frame - // whether the work is done, and then shows the highlight under the pointer. + // The tool works on another thread (a large selection's planar map or merge): it checks each + // frame whether the work is done, and then shows the highlight under the pointer or applies + // the edit. if app.session.tool_waiting() { - if let Err(e) = app.session.tool_poll(view, false) { - app.status(e.to_string()); - } + let r = app.session.tool_poll(view, false); + apply_requests(app, r); ui.ctx().request_repaint_after(std::time::Duration::from_millis(16)); } if pointer.primary_released() @@ -202,12 +202,20 @@ fn wheel_view( ) -> (egui::PointerState, egui::Modifiers, bool, Option) { let line = ui.ctx().options(|o| o.input_options.line_scroll_speed); let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel; - let animate = animated_zoom(&app.session.prefs); + // A docked field may be drawn later and has not consumed its wheel event yet. Only canvas + // hover may start a zoom glide; existing glides still decay outside the canvas in `wheel`. + let animate = animated_zoom(&app.session.prefs) && resp.hovered(); let turn_id = egui::Id::unique("canvas-wheel-turn"); let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default(); let mut turn = turn_before; - let (pointer, m, space, (factor, scroll)) = - ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, animate, line, rect.height(), &mut turn))); + let (pointer, m, space, (factor, scroll)) = ui.input(|i| { + ( + i.pointer.clone(), + i.modifiers, + i.key_down(egui::Key::Space), + wheel(i, &app.session.prefs, wheel_zooms, animate, line, rect.height(), &mut turn), + ) + }); if turn != turn_before { ui.data_mut(|d| d.insert_temp(turn_id, turn)); } diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 8779380ec..fb9872a77 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -110,9 +110,10 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: // The Selection tool's bounding box (rotated with the objects after a rotation), also shown by // the shape tools that keep a shape just drawn ready to adjust while they draw none (#5477). It // hides while a drag moves or resizes the selection, so only the art is seen going with the - // pointer (#712). + // pointer (#712). A handle resize keeps it at the changing bounds (#3526). // With Real-time Drawing off the art waits for the release of a move drag, and the box moves // with the pointer instead. + let resizing = app.session.tool_resizing(); let moved = app.session.deferred_move(); let shape_tool = vectorcraft_tools::shape::shows_shape_widgets(app.session.tool_id()); // A shape tool drawing a shape shows none of this; adjusting a widget, it shows the widgets. @@ -122,10 +123,18 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: let held = super::drag::mods(p.ctx().input(|i| i.modifiers), false); let lends_selection = !app.session.tool_busy() && app.session.lent_tool_id(&held) == Some("selection"); let show_box = (app.session.tool_id() == "selection" || lends_selection || (shape_tool && !app.session.tool_busy())) - && (!app.session.tool_transforming() || moved.is_some()) + && (resizing || !app.session.tool_transforming() || moved.is_some()) && 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 a resize measures the + // box 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 bbox = bbox.map(|b| match moved { Some(d) => vectorcraft_doc::OrientedBox { rect: b.rect + b.to_local(d.to_point()).to_vec2(), ..b }, None => b, @@ -275,6 +284,29 @@ pub(super) fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: match o { // Where the tools select: the document rectangle, turned with the view. Overlay::Marquee(r) => marquee_through(p, &xf.quad(*r)), + Overlay::SelectionMarquee { rect, enclosed, pointer } => { + let quad = xf.quad(*rect); + if *enclosed { + let points: Vec = quad.iter().chain(quad.first()).copied().collect(); + p.add(Shape::line(points.clone(), Stroke::new(3.0, Color32::from_white_alpha(230)))); + p.add(Shape::line(points, Stroke::new(1.0, Color32::from_gray(40)))); + } else { + marquee_through(p, &quad); + } + let (current, other) = if *enclosed { (tl!("Fully Enclosed"), tl!("Touching")) } else { (tl!("Touching"), tl!("Fully Enclosed")) }; + let label = format!("{current} · E: {other}"); + let galley = p.layout(label, egui::FontId::proportional(11.0), t.text, (p.clip_rect().width() - 12.0).max(1.0)); + let size = galley.size() + vec2(12.0, 8.0); + let wanted = xf.to_screen(*pointer) + vec2(14.0, 14.0); + let clip = p.clip_rect(); + let min = egui::pos2( + wanted.x.clamp(clip.min.x, (clip.max.x - size.x).max(clip.min.x)), + wanted.y.clamp(clip.min.y, (clip.max.y - size.y).max(clip.min.y)), + ); + let badge = egui::Rect::from_min_size(min, size); + p.rect_filled(badge, CornerRadius::same(3), t.panel); + p.galley(badge.min + vec2(6.0, 4.0), galley, t.text); + } Overlay::Path { path, color, width, dashed } => { let s = Stroke::new(*width, c32(*color)); if *dashed { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index d9fa7f09c..5a94afb3f 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2,6 +2,65 @@ use super::*; use vectorcraft_engine::Session; use vectorcraft_geom::Shape as _; +/// The marquee explains E without depending on color. At the canvas edge the hint stays in +/// view, in every brightness, and the two rules have visibly different outlines. +#[test] +fn marquee_feedback_explains_the_rule_and_stays_on_canvas() { + let draw = |ui: &mut egui::Ui| { + let t = Tokens::get(ui.ctx()); + let xf = Xf { rect: ui.max_rect(), zoom: 1.0, center: Point::new(160.0, 100.0), rot: 0.0 }; + let prefs = vectorcraft_engine::Prefs::default(); + for (enclosed, y) in [(false, 80.0), (true, 180.0)] { + overlays::draw_overlays( + ui.painter(), + &xf, + &[Overlay::SelectionMarquee { rect: Rect::new(20.0, y - 60.0, 300.0, y), enclosed, pointer: Point::new(315.0, y) }], + &t, + overlays::HandleLook::of(&prefs), + ); + } + }; + for brightness in crate::theme::Brightness::ALL { + for width in [160.0, 320.0] { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, brightness); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(width, 200.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| draw(ui)); + out.textures_delta.clear(); + let text = crate::tests_labels::shapes_text(&out); + assert!(text.contains("Fully Enclosed · E: Touching") && text.contains("Touching · E: Fully Enclosed"), "{text}"); + for shape in &out.shapes { + if let egui::Shape::Text(label) = &shape.shape { + assert!( + label.pos.x >= shape.clip_rect.min.x && label.pos.x + label.galley.size().x <= shape.clip_rect.max.x, + "{brightness:?}: hint clipped" + ); + assert!( + label.pos.y >= shape.clip_rect.min.y && label.pos.y + label.galley.size().y <= shape.clip_rect.max.y, + "{brightness:?}: hint clipped vertically" + ); + } + } + } + } + // Optional visual QA of our own UI. Captures never enter the repository. + if let Some(path) = std::env::var_os("VECTORCRAFT_MARQUEE_CAPTURE") { + let mut ready = false; + let mut harness = egui_kittest::Harness::builder().with_size(vec2(320.0, 200.0)).wgpu().build_ui(move |ui| { + if !ready { + crate::theme::install_fonts(ui.ctx()); + crate::theme::apply(ui.ctx(), crate::theme::Brightness::MediumDark); + ready = true; + return; + } + draw(ui); + }); + harness.run_steps(4); + harness.render().unwrap().save(path).unwrap(); + } +} + #[test] fn subpixel_dock_layout_does_not_change_pointer_document_coordinates() { let mut observed = Vec::new(); @@ -28,6 +87,60 @@ fn subpixel_dock_layout_does_not_change_pointer_document_coordinates() { assert_ne!(observed[0], observed[2], "a real device-pixel resize must still change the mapping"); } +/// A document's opening view is fitted to the canvas of the first frame that shows it, and the +/// window's layout can still move the canvas's edges over the next frames. Two starts of the app +/// then showed one file at zooms a hair apart, on canvases alike once the window had settled. So +/// until the view is moved or the user presses, types or scrolls, the opening fit follows the +/// canvas. +#[test] +fn the_opening_fit_follows_the_canvas_until_the_view_moves_or_the_user_acts() { + // A frame that shows the canvas `width` points wide, which limits the fit of the 800 × 600 + // artboard, with `events`: the view after it. + fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, width: f32, events: Vec) -> View { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1800.0, 900.0))), + max_texture_side: Some(8192), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + let area = egui::Rect::from_min_size(pos2(54.0, 72.0), vec2(width + SCROLL_BAR, 636.0 + SCROLL_BAR)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area)); + show(app, &mut child); + }); + out.textures_delta.clear(); + *app.view().unwrap() + } + fn opened() -> (VectorcraftApp, egui::Context) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 800, "height": 600, "created": 0})).unwrap(); + (app, egui::Context::default()) + } + let first_shown_at = |width: f32| { + let (mut app, ctx) = opened(); + frame(&mut app, &ctx, width, vec![]).zoom + }; + let (narrow, wide) = (first_shown_at(700.0), first_shown_at(702.0)); + assert!(narrow < wide, "the canvas's width limits the fit: {narrow} and {wide}"); + let escape = || egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + + // The first frame lays the canvas out 2 points narrower than the next: the document opens at + // the zoom of the settled canvas, as it does when the first frame has it already. + let (mut app, ctx) = opened(); + frame(&mut app, &ctx, 700.0, vec![]); + assert_eq!(frame(&mut app, &ctx, 702.0, vec![]).zoom, wide); + // Once the user has pressed a key, the canvas changing keeps the view, as resizing the window + // does. + assert_eq!(frame(&mut app, &ctx, 702.0, vec![escape()]).zoom, wide); + assert_eq!(frame(&mut app, &ctx, 700.0, vec![]).zoom, wide); + + // A view moved before the canvas changes stays where it was moved to. + let (mut app, ctx) = opened(); + frame(&mut app, &ctx, 700.0, vec![]); + app.view_mut().unwrap().zoom = 0.5; + assert_eq!(frame(&mut app, &ctx, 702.0, vec![]).zoom, 0.5); +} + /// A rendered raster becomes the image egui's own conversion makes of it, half-transparent /// edges and all; one whose bytes don't match its size becomes an empty image of that size. #[test] @@ -3818,6 +3931,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. @@ -4306,7 +4509,8 @@ fn show_hidden_characters_releases_the_text_nodes_of_a_closed_document() { } /// General › Zoom with Mouse Wheel (#394), through the control channel's `ui.wheel`: off, -/// the wheel scrolls and Cmd-wheel zooms; on, the wheel zooms about the pointer, Shift-wheel +/// the wheel scrolls and Cmd-wheel scrolls sideways (Scroll Sideways with Cmd/Ctrl + Wheel is on +/// by default); on, the wheel zooms about the pointer, Shift-wheel /// scrolls up and down and Cmd/Ctrl-wheel sideways. Alt-wheel (Option on the Mac) and a /// trackpad pinch zoom about the pointer either way. #[test] @@ -4348,7 +4552,9 @@ fn zoom_with_mouse_wheel_preference() { let zooms_in = |(zoom, _, moved): (f64, _, f64)| zoom > 1.01 && moved < 1e-6; let (zoom, scroll, _) = turn(&mut app, ""); assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "off: the wheel scrolls up ({zoom}, {scroll:?})"); - assert!(zooms_in(turn(&mut app, "cmd")), "off: Cmd-wheel zooms in about the pointer"); + // General › Scroll Sideways with Cmd/Ctrl + Wheel is on by default. + let (zoom, scroll, _) = turn(&mut app, "cmd"); + assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "off: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})"); assert!(zooms_in(turn(&mut app, "alt")), "off: Alt-wheel zooms in about the pointer"); assert!(zooms_in(turn(&mut app, "pinch")), "off: a pinch zooms in about the pointer"); app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap(); @@ -4361,6 +4567,363 @@ fn zoom_with_mouse_wheel_preference() { assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})"); } +/// A numeric panel drawn after the canvas owns its wheel turn even when the pointer enters the +/// canvas on the next frame (#5949). Only a subsequent turn over the canvas starts a zoom glide. +#[test] +fn wheel_over_a_numeric_panel_field_does_not_start_a_canvas_zoom_glide() { + use std::cell::Cell; + for focused in [false, true] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + for key in ["zoomWithMouseWheel", "animatedZoom", "gpuPerformance"] { + app.run("prefs.set", json!({"key": key, "value": true})).unwrap(); + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let value = Cell::new(10.0); + let field_rect = Cell::new(egui::Rect::NOTHING); + let field_id = Cell::new(egui::Id::NULL); + let mut time = 0.0; + let mut run = |app: &mut VectorcraftApp, events| { + time += 0.016; + let raw = egui::RawInput { + time: Some(time), + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + // Match dock rendering order: canvas first, the numeric panel afterward. + let mut canvas_ui = ui.new_child(egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(Pos2::ZERO, vec2(550.0, 500.0)))); + show(app, &mut canvas_ui); + let mut panel_ui = ui.new_child(egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(pos2(600.0, 20.0), vec2(150.0, 100.0)))); + field_id.set(panel_ui.scope_id().with("number")); + if let Some(v) = crate::widgets::plain_field(&mut panel_ui, "number", value.get(), "", 0, 80.0) { + value.set(v); + } + field_rect.set(panel_ui.min_rect()); + }); + out.textures_delta.clear(); + }; + run(&mut app, vec![]); + if focused { + ctx.memory_mut(|m| m.request_focus(field_id.get())); + } + let over_field = field_rect.get().center(); + run(&mut app, vec![egui::Event::PointerMoved(over_field)]); + let before = *app.view().unwrap(); + let turn = || egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }; + run(&mut app, vec![turn()]); + assert_eq!(value.get(), 11.0, "the hovered field steps, focused: {focused}"); + assert_eq!(ctx.memory(|m| m.has_focus(field_id.get())), focused, "hover does not take focus"); + assert_eq!(app.view().unwrap().zoom, before.zoom); + let over_canvas = app.canvas_rect.unwrap().center(); + run(&mut app, vec![egui::Event::PointerMoved(over_canvas)]); + for _ in 0..20 { + run(&mut app, vec![]); + assert_eq!(app.view().unwrap().zoom, before.zoom, "a panel turn cannot leave a canvas glide, focused: {focused}"); + assert_eq!(app.view().unwrap().center, before.center); + } + run(&mut app, vec![turn()]); + let first = app.view().unwrap().zoom; + assert!(first > before.zoom, "a turn over the canvas still zooms"); + for _ in 0..20 { + run(&mut app, vec![]); + } + assert!(app.view().unwrap().zoom > first, "the canvas turn still glides over subsequent frames"); + assert_eq!(value.get(), 11.0, "the canvas turn does not step the field"); + } +} + +/// General › Scroll Sideways with Cmd/Ctrl + Wheel (on by default), through the control channel's +/// `ui.wheel`: with Zoom with Mouse Wheel off, Cmd/Ctrl-wheel zooms about the pointer while the +/// option is off and scrolls sideways while it is on, and a trackpad pinch, two fingers spread on a touch +/// screen, Alt + Cmd/Ctrl-wheel and Alt-wheel still zoom. A Cmd/Ctrl-wheel notch with a plain +/// one right after it scrolls two notches in all, and a trackpad gesture during which Cmd/Ctrl +/// is pressed and released scrolls sideways until the gesture ends, while an Alt gesture that +/// pauses past egui's scroll timeout no longer holds Alt for the next move. With Zoom with +/// Mouse Wheel also on, Cmd/Ctrl-wheel scrolls sideways and the wheel zooms. +#[test] +fn cmd_wheel_scrolls_sideways_preference() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + // A canvas frame through the host's input hook (it feeds the control channel's input). + let run = |app: &mut VectorcraftApp| { + let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + app.raw_input_hook(&ctx, &mut raw); + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + run(&mut app); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() + vec2(60.0, 40.0); + // One notch up at `at` with `mods` held, or a pinch: → (zoom ratio, scroll in points, how + // far the point under `at` moved). + let turn = |app: &mut VectorcraftApp, mods: &str| { + let before = *app.view().unwrap(); + if mods == "pinch" { + app.synthetic.extend([egui::Event::PointerMoved(at), egui::Event::Zoom(1.25)]); + } else { + let mut p = json!({"x": at.x, "y": at.y, "dy": 1}); + for m in mods.split('+').filter(|m| !m.is_empty()) { + p[m] = json!(true); + } + crate::tests_synthetic::control(app, &ctx, "ui.wheel", p); + } + // egui spreads a notch over a few frames. + for _ in 0..40 { + run(app); + } + let after = *app.view().unwrap(); + let moved = Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at)); + (after.zoom / before.zoom, (after.center - before.center) * after.zoom, moved) + }; + let zooms_in = |(zoom, _, moved): (f64, _, f64)| zoom > 1.01 && moved < 1e-6; + assert!(app.session.prefs.ctrl_wheel_scrolls_sideways, "the option is on by default"); + app.session.execute("prefs.set", &json!({"key": "ctrlWheelScrollsSideways", "value": false})).unwrap(); + assert!(zooms_in(turn(&mut app, "cmd")), "off: Cmd-wheel zooms in about the pointer"); + let (zoom, scroll, _) = turn(&mut app, "shift"); + assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "off: Shift-wheel scrolls sideways ({zoom}, {scroll:?})"); + app.session.execute("prefs.set", &json!({"key": "ctrlWheelScrollsSideways", "value": true})).unwrap(); + // A notch scrolls one wheel line. + let line = f64::from(ctx.options(|o| o.input_options.line_scroll_speed)); + let (zoom, scroll, _) = turn(&mut app, "cmd"); + assert!( + (zoom - 1.0).abs() < 1e-9 && (scroll.x + line).abs() < 0.5 && scroll.y == 0.0, + "on: Cmd-wheel scrolls sideways by a notch ({zoom}, {scroll:?})" + ); + assert!(zooms_in(turn(&mut app, "pinch")), "on: a pinch right after it zooms in about the pointer"); + // Two fingers spread on a touch screen, with no wheel turn since the Cmd-wheel one. + let before = app.view().unwrap().zoom; + let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None }; + let fingers = |phase, d: f32| vec![touch(1, phase, at - vec2(40.0 + d, 0.0)), touch(2, phase, at + vec2(40.0 + d, 0.0))]; + frame(&mut app, &ctx, fingers(egui::TouchPhase::Start, 0.0)); + for k in 1..=3 { + frame(&mut app, &ctx, fingers(egui::TouchPhase::Move, 10.0 * k as f32)); + } + frame(&mut app, &ctx, fingers(egui::TouchPhase::End, 30.0)); + let zoom = app.view().unwrap().zoom / before; + assert!(zoom > 1.01, "on: fingers spread on a touch screen zoom in ({zoom})"); + assert!(zooms_in(turn(&mut app, "alt+cmd")), "on: Alt + Cmd-wheel zooms in about the pointer"); + assert!(zooms_in(turn(&mut app, "alt")), "on: so does Alt-wheel"); + let (zoom, scroll, _) = turn(&mut app, ""); + assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "on: the wheel scrolls up ({zoom}, {scroll:?})"); + let (zoom, scroll, _) = turn(&mut app, "shift"); + assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Shift-wheel scrolls sideways ({zoom}, {scroll:?})"); + // A Cmd-wheel notch with a plain one right after it, before egui has spread the first: they + // scroll two notches in all, sideways and up. + let before = *app.view().unwrap(); + crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "cmd": true})); + crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1})); + for _ in 0..40 { + run(&mut app); + } + let after = *app.view().unwrap(); + let scroll = (after.center - before.center) * after.zoom; + assert!( + scroll.x < -1.0 && scroll.y < -1.0 && (scroll.x + scroll.y + 2.0 * line).abs() < 0.5, + "on: a Cmd-wheel notch and a plain one right after it scroll two notches in all ({scroll:?})" + ); + // A pinch while egui still spreads a Cmd-wheel notch zooms by the pinch alone, and the + // canvas scrolls sideways by one notch; the pinch magnifies the part scrolled before it. + let before = *app.view().unwrap(); + let p = Xf::new(rect, &before).to_doc(at); + crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "cmd": true})); + app.synthetic.extend([egui::Event::PointerMoved(at), egui::Event::Zoom(1.25)]); + for _ in 0..40 { + run(&mut app); + } + let after = *app.view().unwrap(); + let (zoom, moved) = (after.zoom / before.zoom, Xf::new(rect, &after).to_screen(p) - at); + let notch = line as f32; + assert!( + (zoom - 1.25).abs() < 1e-6 && moved.x > notch - 0.5 && moved.x < 1.25 * notch + 0.5 && moved.y.abs() < 1e-3, + "on: a pinch during a Cmd-wheel notch zooms by the pinch, and the notch scrolls sideways ({zoom}, {moved:?})" + ); + // A trackpad gesture starts with no modifier, and Cmd is pressed and released during it: + // egui keeps Cmd in its wheel state until the gesture ends, and the canvas keeps scrolling + // sideways until then. + let before = *app.view().unwrap(); + let swipe = |phase, modifiers| egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 6.0), + phase, + modifiers, + source: egui::MouseWheelSource::Unknown, + }; + app.synthetic.extend([ + egui::Event::PointerMoved(at), + swipe(egui::TouchPhase::Start, egui::Modifiers::NONE), + swipe(egui::TouchPhase::Move, egui::Modifiers::COMMAND), + swipe(egui::TouchPhase::Move, egui::Modifiers::NONE), + swipe(egui::TouchPhase::End, egui::Modifiers::NONE), + ]); + for _ in 0..40 { + run(&mut app); + } + let after = *app.view().unwrap(); + let scroll = (after.center - before.center) * after.zoom; + assert!( + (after.zoom / before.zoom - 1.0).abs() < 1e-9 && (scroll.x + 12.0).abs() < 0.01 && scroll.y == 0.0, + "on: a trackpad gesture scrolls sideways to its end after Cmd is let go ({}, {scroll:?})", + after.zoom / before.zoom + ); + // An Alt-wheel gesture pauses past egui's scroll timeout with no frame in between: egui + // ends it before the next event, so a move with Cmd alone scrolls sideways. + // Frames at the given times, with the events in the frame's own input (the host's input + // hook hands synthetic events out over several frames). + let run_at = |app: &mut VectorcraftApp, time: f64, events: Vec| { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + time: Some(time), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + // The Alt part rests the fingers without moving, so only the Cmd move below acts. + let rest = |phase| egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: Vec2::ZERO, + phase, + modifiers: egui::Modifiers::ALT, + source: egui::MouseWheelSource::Unknown, + }; + let t = ctx.input(|i| i.time) + 1.0; + run_at(&mut app, t, vec![egui::Event::PointerMoved(at), rest(egui::TouchPhase::Start), rest(egui::TouchPhase::Move)]); + let before = *app.view().unwrap(); + run_at(&mut app, t + 2.0, vec![swipe(egui::TouchPhase::Move, egui::Modifiers::COMMAND)]); + for k in 1..=40 { + run_at(&mut app, t + 2.0 + f64::from(k) / 60.0, vec![]); + } + let after = *app.view().unwrap(); + let scroll = (after.center - before.center) * after.zoom; + assert!( + (after.zoom / before.zoom - 1.0).abs() < 1e-9 && (scroll.x + 6.0).abs() < 0.01 && scroll.y == 0.0, + "on: after egui's timeout, a Cmd move scrolls sideways ({}, {scroll:?})", + after.zoom / before.zoom + ); + app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap(); + let (zoom, scroll, _) = turn(&mut app, "cmd"); + assert!( + (zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, + "with Zoom with Mouse Wheel: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})" + ); + assert!(zooms_in(turn(&mut app, "")), "with Zoom with Mouse Wheel: the wheel zooms in about the pointer"); +} + +/// With Scroll Sideways with Cmd/Ctrl + Wheel on and Animated Zoom off, Alt + Cmd-wheel zooms +/// about the pointer by egui's Cmd-wheel zoom, as Cmd-wheel does with the option off, and +/// Cmd-wheel alone scrolls sideways. +#[test] +fn cmd_wheel_sideways_alt_cmd_zooms_without_animated_zoom() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + for (key, value) in [("ctrlWheelScrollsSideways", true), ("zoomWithMouseWheel", false), ("animatedZoom", false)] { + app.session.execute("prefs.set", &json!({"key": key, "value": value})).unwrap(); + } + let ctx = egui::Context::default(); + let run = |app: &mut VectorcraftApp| { + let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + app.raw_input_hook(&ctx, &mut raw); + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + run(&mut app); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() + vec2(60.0, 40.0); + let before = *app.view().unwrap(); + crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "alt": true, "cmd": true})); + for _ in 0..40 { + run(&mut app); + } + let after = *app.view().unwrap(); + let expected = ctx.options(|o| f64::from(o.input_options.line_scroll_speed * o.input_options.scroll_zoom_speed).exp()); + let zoom = after.zoom / before.zoom; + assert!((zoom - expected).abs() < 1e-3, "Alt + Cmd-wheel zooms in by one notch ({zoom}, expected {expected})"); + assert!(Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at)) < 1e-6, "Alt + Cmd-wheel zooms about the pointer"); + + let before = after; + crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "cmd": true})); + for _ in 0..40 { + run(&mut app); + } + let after = *app.view().unwrap(); + let scroll = (after.center - before.center) * after.zoom; + let line = f64::from(ctx.options(|o| o.input_options.line_scroll_speed)); + assert!((after.zoom / before.zoom - 1.0).abs() < 1e-9, "Cmd-wheel does not zoom"); + assert!((scroll.x + line).abs() < 0.5 && scroll.y == 0.0, "Cmd-wheel scrolls sideways ({scroll:?})"); +} + +/// With Zoom with Mouse Wheel off, a trackpad gesture that starts with Alt keeps zooming about the +/// pointer until it ends, also after Alt is let go, as egui keeps a modifier held during a gesture, +/// with Animated Zoom on and off. A gesture that gains Alt partway zooms from then on. +#[test] +fn alt_wheel_gesture_keeps_zooming_after_alt_is_let_go() { + for animate in [true, false] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + for (key, value) in [("zoomWithMouseWheel", false), ("animatedZoom", animate)] { + app.session.execute("prefs.set", &json!({"key": key, "value": value})).unwrap(); + } + let ctx = egui::Context::default(); + let run = |app: &mut VectorcraftApp| { + let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + app.raw_input_hook(&ctx, &mut raw); + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + run(&mut app); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() + vec2(60.0, 40.0); + let swipe = |phase, modifiers| egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 6.0), + phase, + modifiers, + source: egui::MouseWheelSource::Unknown, + }; + // One gesture with the given modifiers on its Start and its two Moves → (zoom ratio, how far + // the point under `at` moved). + let gesture = |app: &mut VectorcraftApp, mods: [egui::Modifiers; 3]| { + let before = *app.view().unwrap(); + app.synthetic.extend([ + egui::Event::PointerMoved(at), + swipe(egui::TouchPhase::Start, mods[0]), + swipe(egui::TouchPhase::Move, mods[1]), + swipe(egui::TouchPhase::Move, mods[2]), + swipe(egui::TouchPhase::End, egui::Modifiers::NONE), + ]); + for _ in 0..40 { + run(app); + } + let after = *app.view().unwrap(); + (after.zoom / before.zoom, Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at))) + }; + let alt = egui::Modifiers::ALT; + let (held, _) = gesture(&mut app, [alt, alt, alt]); + assert!(held > 1.01, "animate {animate}: an Alt gesture zooms in ({held})"); + let (zoom, moved) = gesture(&mut app, [alt, alt, egui::Modifiers::NONE]); + assert!( + (zoom - held).abs() < 1e-9 && moved < 1e-6, + "animate {animate}: a move without Alt keeps the gesture zooming about the pointer ({zoom}, expected {held}; moved {moved})" + ); + let (zoom, moved) = gesture(&mut app, [egui::Modifiers::NONE, alt, egui::Modifiers::NONE]); + assert!( + (zoom - held).abs() < 1e-9 && moved < 1e-6, + "animate {animate}: Alt pressed partway zooms the rest of the gesture about the pointer ({zoom}, expected {held}; moved {moved})" + ); + } +} + /// #888: with Zoom with Mouse Wheel, a wheel notch's zoom glides in over a few frames, as a /// scroll does, instead of jumping on the frame it arrives; it ends at the same zoom. #[test] diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index c0e2a4bc0..39bc80545 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -52,8 +52,19 @@ const WHEEL_GLIDE: f64 = 0.05; /// What the wheel did across frames ([`wheel`]). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub(super) struct WheelTurn { - /// The last wheel turn was an Alt-wheel one. + /// The last wheel turn was an Alt-wheel one: `held` had Alt without Cmd/Ctrl at its last Start + /// or Move. alt: bool, + /// The modifiers egui's wheel state holds, followed while Zoom with Mouse Wheel is off: each + /// wheel event's own, or, through a trackpad gesture, all those held since it began; none + /// while the wheel is idle. + held: egui::Modifiers, + /// A trackpad gesture is under way: egui has seen its Start phase and has not yet seen its End + /// or Cancel phase. + gesture: bool, + /// When the last wheel event came (`InputState::time`), and whether a trackpad sent it. + last: f64, + trackpad: bool, /// The zoom (natural log) wheel notches still have to glide in. pub(super) glide: f64, } @@ -342,13 +353,22 @@ struct ThumbDrag { /// What the wheel, a pinch and a two-finger drag on a touch screen did over the canvas this frame: /// a zoom factor (about the pointer) and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option -/// on the Mac) zoom. With General › Zoom with Mouse Wheel (`wheel_zooms`) the wheel and Alt-wheel -/// zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points +/// on the Mac) zoom; with General › Scroll Sideways with Cmd/Ctrl + Wheel, Cmd/Ctrl-wheel scrolls +/// sideways instead, and Alt + Cmd/Ctrl-wheel still zooms. With General › Zoom with Mouse Wheel the +/// wheel and Alt-wheel zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points /// per wheel line and page. `turn`: kept by the caller across frames. With Performance › Animated /// Zoom (`animate`) a mouse wheel's notches zoom in a glide over a few frames, as scrolling does /// (#888); off, each notch zooms on the frame it arrives (#4283). A trackpad's fine steps zoom at /// once either way. -pub(super) fn wheel(i: &egui::InputState, wheel_zooms: bool, animate: bool, line: f32, page: f32, turn: &mut WheelTurn) -> (f64, egui::Vec2) { +pub(super) fn wheel( + i: &egui::InputState, + prefs: &vectorcraft_engine::Prefs, + wheel_zooms: bool, + animate: bool, + line: f32, + page: f32, + turn: &mut WheelTurn, +) -> (f64, egui::Vec2) { // Fingers on a touch screen pan the content with them (a trackpad sends scrolls instead). let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |t| t.translation_delta); // A wheel event's motion in points. @@ -360,17 +380,72 @@ pub(super) fn wheel(i: &egui::InputState, wheel_zooms: bool, animate: bool, line if !wheel_zooms { // egui makes Cmd-wheel (and a pinch) its zoom and the rest a scroll it spreads over a few // frames: the rest of an Alt-wheel turn zooms too, however soon Alt is let go. - if let Some(m) = i.events.iter().rev().find_map(|e| if let egui::Event::MouseWheel { modifiers, .. } = e { Some(modifiers) } else { None }) { - turn.alt = m.alt && !m.command; + // Each wheel event changes `turn.held` and `turn.gesture` as `WheelState::on_wheel_event` + // changes egui's wheel modifiers and status. Both are cleared while egui's wheel is idle. + // `turn.alt` follows `turn.held` after each Start or Move, so a trackpad gesture that held + // Alt keeps zooming until it ends, also after Alt is let go. + for e in &i.events { + if let egui::Event::MouseWheel { phase, modifiers, source, .. } = e { + // A wheel event that comes after egui's scroll timeout ends the turn first, as + // `on_wheel_event` does (1 s for fingers resting on a trackpad mid-gesture, else + // 0.15 s), also when no frame ran in between. + let timeout = if turn.gesture && turn.trackpad { 1.0 } else { 0.150 }; + if i.time - turn.last > timeout { + turn.gesture = false; + turn.held = egui::Modifiers::NONE; + } + turn.last = i.time; + turn.trackpad = *source == egui::MouseWheelSource::Trackpad; + match phase { + egui::TouchPhase::Start => { + turn.gesture = true; + turn.held = *modifiers; + turn.alt = turn.held.alt && !turn.held.command; + } + egui::TouchPhase::Move => { + if turn.gesture { + turn.held |= *modifiers; + } else { + turn.held = *modifiers; + } + turn.alt = turn.held.alt && !turn.held.command; + } + egui::TouchPhase::End | egui::TouchPhase::Cancel => { + turn.gesture = false; + turn.held = egui::Modifiers::NONE; + } + } + } + } + if !i.is_scrolling() { + turn.gesture = false; + turn.held = egui::Modifiers::NONE; + } + let (mut zoom, mut scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta); + // Scroll Sideways with Cmd/Ctrl + Wheel: while egui's wheel state holds Cmd/Ctrl without + // Alt, egui's zoom, less the pinches', is `exp(points × scroll_zoom_speed)`, at egui's + // default speed, which the app keeps. The canvas scrolls sideways by those points instead. + // A trackpad or web pinch still zooms. With fingers on a touch screen, egui's zoom is the + // fingers' alone, and the canvas leaves it as it is. + if prefs.ctrl_wheel_scrolls_sideways && turn.held.command && !turn.held.alt && i.multi_touch().is_none() { + let pinch: f64 = i.events.iter().filter_map(|e| if let egui::Event::Zoom(f) = e { Some(f64::from(*f)) } else { None }).product(); + let wheel_zoom = zoom / pinch; + if wheel_zoom.is_finite() && wheel_zoom > 0.0 { + scroll.x += (wheel_zoom.ln() / f64::from(egui::InputOptions::default().scroll_zoom_speed)) as f32; + zoom = pinch; + } } - let (zoom, scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta); if turn.alt && !animate { // Without Animated Zoom, Alt-wheel zooms by the wheel events themselves, on the frame - // they arrive; the rest of egui's spread scroll is dropped. + // they arrive; the rest of egui's spread scroll is dropped. Only Move events count, as + // `on_wheel_event` moves egui's scroll by Move events alone. let d: f32 = i .events .iter() - .filter_map(|e| if let egui::Event::MouseWheel { unit, delta, .. } = e { Some(points(unit, *delta)) } else { None }) + .filter_map(|e| match e { + egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, .. } => Some(points(unit, *delta)), + _ => None, + }) .map(|d| d.x + d.y) .sum(); return (zoom * (f64::from(d) * WHEEL_ZOOM).exp(), pan); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index ae8fbccc0..f49851ad2 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -77,7 +77,16 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let full = ui.max_rect(); let right_edge = if custom { full.right() - titlebar::WIDTH - 10.0 } else { full.right() }; let right = egui::Rect::from_min_max(egui::pos2(menus_end + 8.0, full.top()), egui::pos2(right_edge, full.bottom())); - let room = right.width(); + // In Safe Mode, a label that stays in the bar whatever document is open, and opens + // Help › Safe Mode; the other controls fit in the room it leaves, and the workspace + // switcher (also under Window › Workspace) goes when a minimal one doesn't fit. + let safe = vectorcraft_plugins::safe_mode::active().then(|| egui::RichText::new(tl!("Safe Mode")).color(t.warning).size(12.0)); + let safe_w = safe.as_ref().map_or(0.0, |label| { + let text = ui.painter().layout_no_wrap(label.text().to_string(), egui::FontId::proportional(12.0), t.warning); + text.size().x + 2.0 * ui.spacing().button_padding.x + 8.0 + ui.spacing().item_spacing.x + }); + let room = (right.width() - safe_w).max(0.0); + let switcher = safe.is_none() || room >= 64.0 + 8.0; // Built-in workspace names translate; the user's own stay as typed. let ws_name = if crate::workspaces::is_builtin(&app.ui.workspace) { tl!(&app.ui.workspace).to_string() } else { app.ui.workspace.clone() }; @@ -86,19 +95,27 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let gap = ui.spacing().item_spacing.x; // The search icon, as in PhotoCraft: shown while a minimal switcher fits beside it. let icon = 28.0; - let search = room >= 64.0 + 8.0 + gap + icon; + let search = switcher && room >= 64.0 + 8.0 + gap + icon; let ws_w = ws_w.min(room - 8.0 - gap - if search { icon } else { 0.0 }).max(64.0); let discord = search && room >= ws_w + 8.0 + gap + icon + 10.0 + gap + crate::community::discord_width(ui, false); let mut rui = ui.new_child(egui::UiBuilder::new().max_rect(right).layout(egui::Layout::right_to_left(egui::Align::Center))); let ui = &mut rui; // Workspace switcher: shows the current workspace, opens Window → Workspace. - let (wr, wresp) = ui.allocate_exact_size(vec2(ws_w, WIDGET_H), Sense::click()); - ui.painter().rect_filled(wr, CornerRadius::same(4), if wresp.hovered() { t.hover } else { t.panel }); - ui.painter().with_clip_rect(wr.shrink2(vec2(4.0, 0.0))).galley(wr.left_center() + vec2(10.0, -ws.size().y / 2.0), ws, t.text); - icons::paint(ui, "chevron-down", egui::Rect::from_center_size(wr.right_center() - vec2(12.0, 0.0), vec2(12.0, 12.0)), t.text_dim); - let wresp = wresp.on_hover_text(tl!("Switch workspace")); - egui::Popup::menu(&wresp).show(|ui| crate::workspaces::popup(app, ui)); - ui.add_space(8.0); + if switcher { + let (wr, wresp) = ui.allocate_exact_size(vec2(ws_w, WIDGET_H), Sense::click()); + ui.painter().rect_filled(wr, CornerRadius::same(4), if wresp.hovered() { t.hover } else { t.panel }); + ui.painter().with_clip_rect(wr.shrink2(vec2(4.0, 0.0))).galley(wr.left_center() + vec2(10.0, -ws.size().y / 2.0), ws, t.text); + icons::paint(ui, "chevron-down", egui::Rect::from_center_size(wr.right_center() - vec2(12.0, 0.0), vec2(12.0, 12.0)), t.text_dim); + let wresp = wresp.on_hover_text(tl!("Switch workspace")); + egui::Popup::menu(&wresp).show(|ui| crate::workspaces::popup(app, ui)); + ui.add_space(8.0); + } + if let Some(label) = safe { + if ui.add(egui::Button::new(label).frame(false)).on_hover_text(tl!("Safe Mode…")).clicked() { + app.run("help.safeMode", json!({})).ok(); + } + ui.add_space(8.0); + } // Search → command palette (its shortcut opens it too). if search { let tip = match menus::shortcut_of("help.commandPalette") { @@ -129,23 +146,27 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { } } -/// Whether the window shows the system's title bar instead of the app drawing its own: Windows -/// and Linux with Preferences › User Interface › System Title Bar. macOS always has system -/// decorations, but keeps the in-app brand mark, so it never counts as system mode here; nor -/// does the web build (the browser tab has no document title), nor an embedder that never -/// turned the preference on. +/// Whether the window shows the system's title bar instead of the app drawing its own +/// ([`VectorcraftApp::desktop_frame`]): Windows with Preferences › User Interface › System Title +/// Bar, and Linux whenever the desktop draws the frame, with or without the preference. The +/// preference is read when the app starts, so changing it later does not change this. macOS +/// always has system decorations, but keeps the in-app brand mark, so it never counts as system +/// mode here; nor does the web build (the browser tab has no document title), nor an embedder +/// without a window of its own. pub fn system_title_bar(app: &VectorcraftApp) -> bool { - !app.custom_titlebar && app.session.prefs.system_title_bar && !cfg!(any(target_os = "macos", target_arch = "wasm32")) + app.desktop_frame && !app.custom_titlebar && !cfg!(any(target_os = "macos", target_arch = "wasm32")) } /// The OS window title: the active document's name suffixed with the app name (a `*` prefix /// marks unsaved changes, like the `*` in the document tabs), or just the app name with no -/// document open. +/// document open; in Safe Mode, followed by "(Safe Mode)". pub fn window_title(app: &VectorcraftApp) -> String { - app.session + let title = app + .session .active() .map(|d| format!("{}{} \u{2014} VectorCraft", if d.is_dirty() { "*" } else { "" }, d.title())) - .unwrap_or_else(|| "VectorCraft".into()) + .unwrap_or_else(|| "VectorCraft".into()); + if vectorcraft_plugins::safe_mode::active() { format!("{title} ({})", tl!("Safe Mode")) } else { title } } /// Keep the OS window title (and the taskbar / Alt-Tab entry) on the active file. Sends diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index 9a1ffbfbb..9fbd02327 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -4,7 +4,7 @@ use super::{Arc, ROTATE_VIEW_ANGLES, Sense, Stroke, Tokens, Ui, VectorcraftApp, /// Draw the status message, bounded readouts and artboard navigation controls. pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - egui::Panel::bottom("status_bar") + let panel = egui::Panel::bottom("status_bar") .exact_size(24.0) .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 0)).stroke(Stroke::new(1.0, t.border))) .show(ui, |ui| { @@ -84,6 +84,7 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { status_readouts(app, ui, &t); }); }); + widgets::set_menu_floor(ui.ctx(), panel.response.rect.top()); } /// The most recently measured status widths, scoped to this UI and display settings. diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 2799fe1cd..15c856cd8 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -64,6 +64,68 @@ use crate::theme::Tokens; use serde_json::json; use vectorcraft_engine::Session; +#[test] +fn help_bar_and_status_bar_render_independently() { + for (help, status, screen_mode) in [(true, true, 0), (false, true, 0), (true, false, 0), (false, false, 0), (true, true, 3)] { + let (mut app, ctx) = status_readout_app(); + app.ui.help_bar = help; + app.ui.status_bar = status; + app.ui.screen_mode = screen_mode; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + let mut parent = None; + let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| { + parent = Some(ui.scope_id()); + app.ui(ui); + }); + output.textures_delta.clear(); + for _ in 0..2 { + output = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| app.ui(ui)); + output.textures_delta.clear(); + } + fn text(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| text(s, out)), + _ => {} + } + } + let mut shown = Vec::new(); + for shape in &output.shapes { + text(&shape.shape, &mut shown); + } + assert_eq!(shown.iter().any(|t| t == "?"), help && screen_mode < 3, "{help}/{status}/{screen_mode}: {shown:?}"); + assert_eq!(shown.iter().any(|t| t.contains("Saved recovery data")), status && screen_mode < 3, "{help}/{status}/{screen_mode}: {shown:?}"); + let floor = ctx.data(|data| data.get_temp::(egui::Id::unique("menu_floor").with(ctx.viewport_id()))).unwrap(); + let expected = if screen_mode < 3 && (help || status) { + let id = parent.unwrap().with(if help { "hint_bar" } else { "status_bar" }); + egui::containers::panel::PanelState::load(&ctx, id).unwrap().outer_rect.top() + } else { + screen.bottom() + }; + assert!((floor - expected).abs() < 1.0, "menu floor {floor}, expected {expected}"); + } +} + +#[test] +#[ignore = "Renders the Help Bar visibility examples; set HELP_BAR_SCREENSHOTS to an output directory"] +fn help_bar_visibility_screenshots() { + let dir = std::path::PathBuf::from(std::env::var_os("HELP_BAR_SCREENSHOTS").expect("output directory")); + for shown in [true, false] { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 500, "height": 400})).unwrap(); + if !shown { + crate::menus::run_ui_command(&mut app, "window.helpBar", &serde_json::Value::Null).unwrap().unwrap(); + } + let mut h = egui_kittest::Harness::builder() + .with_size(egui::vec2(1000.0, 700.0)) + .with_accessibility_check(false) + .wgpu() + .build_ui_state(|ui, app: &mut crate::VectorcraftApp| app.ui(ui), app); + h.run_steps(6); + h.render().unwrap().save(dir.join(if shown { "help-visible.png" } else { "help-hidden.png" })).unwrap(); + } +} + #[test] fn hints_name_keys_as_the_menus_do() { let text = |tool: &str| super::hint_segments(tool).into_iter().map(|(s, _)| s).collect::(); @@ -1230,16 +1292,51 @@ fn the_zoom_hint_follows_animated_zoom() { fn title_test_app(custom_titlebar: bool) -> crate::VectorcraftApp { let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); app.custom_titlebar = custom_titlebar; + app.desktop_frame = !custom_titlebar; // The preference that makes the shell launch without its own title bar. app.run("prefs.set", json!({"key": "systemTitleBar", "value": !custom_titlebar})).unwrap(); app } +/// How many brand marks one frame of the app bar paints. +fn brand_marks(app: &mut crate::VectorcraftApp) -> usize { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::app_bar(app, ui)); + out.textures_delta.clear(); + out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Mesh(m) if m.texture_id == crate::brand::texture_id(&ctx))).count() +} + #[test] fn without_the_preference_the_mark_stays() { // The default (e.g. the web build): no custom bar, no preference. let app = crate::VectorcraftApp::new(Session::new(), Default::default()); assert!(!super::system_title_bar(&app)); + // An embedder without a window of its own keeps the mark with the preference on too. + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("prefs.set", json!({"key": "systemTitleBar", "value": true})).unwrap(); + assert!(!super::system_title_bar(&app)); + assert_eq!(brand_marks(&mut app), 1); +} + +#[test] +fn a_desktop_frame_hides_the_mark_whatever_the_preference_says() { + // Linux on a desktop that draws the frame (KDE, X11): the window has the desktop's title bar + // with System Title Bar off. + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.desktop_frame = true; + app.run("prefs.set", json!({"key": "systemTitleBar", "value": false})).unwrap(); + let hidden = !cfg!(target_os = "macos"); + assert_eq!(super::system_title_bar(&app), hidden); + assert_eq!(brand_marks(&mut app), if hidden { 0 } else { 1 }); + // The preference applies at the next start: changing it now leaves the window's frame, and the mark, as they are. + let mut app = title_test_app(false); + app.run("prefs.set", json!({"key": "systemTitleBar", "value": false})).unwrap(); + assert_eq!(super::system_title_bar(&app), hidden); + let mut app = title_test_app(true); + app.run("prefs.set", json!({"key": "systemTitleBar", "value": true})).unwrap(); + assert!(!super::system_title_bar(&app)); + assert_eq!(brand_marks(&mut app), 1); } #[test] @@ -2023,7 +2120,8 @@ fn menus_stop_above_a_control_bar_docked_at_the_bottom() { let ctx = egui::Context::default(); let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); let floor = |app: &mut crate::VectorcraftApp, status: bool, bottom: bool, screen_mode: u8| { - (app.ui.status_bar, app.ui.control_bar, app.ui.control_bar_bottom, app.ui.screen_mode) = (status, true, bottom, screen_mode); + (app.ui.status_bar, app.ui.help_bar, app.ui.control_bar, app.ui.control_bar_bottom, app.ui.screen_mode) = + (status, status, true, bottom, screen_mode); for _ in 0..3 { let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| { app.logic(ui.ctx()); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index fccfbedab..6af51d95a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -96,6 +96,13 @@ fn reserve_synthetic(app: &mut VectorcraftApp, additional: usize) -> Result<(), pub type ControlResponse = Value; +pub(crate) struct PendingPointerReply { + pub from: u64, + pub to: u64, + pub document: Option, + pub reply: Sender, +} + pub struct ControlRequest { pub method: String, pub params: Value, @@ -167,6 +174,34 @@ pub enum Outcome { path: Option, data: bool, }, + /// A `ui.pointer` reply ([`pointer_reply`]), sent once the events numbered `from..to` + /// ([`vectorcraft_engine::Session::input_applied`]) have been made and the edit they started + /// on another thread is applied, so it shows the selection after the edit. + AfterToolEdit { + from: u64, + to: u64, + document: Option, + }, +} + +/// The reply to `ui.pointer`: the selection, the key object and the active tool. +pub(crate) fn pointer_reply(app: &mut VectorcraftApp) -> Value { + pointer_reply_for(app, app.session.active().map(|d| d.uid)) +} + +pub(crate) fn pointer_reply_for(app: &VectorcraftApp, document: Option) -> Value { + let state = document.and_then(|uid| app.session.documents().iter().find(|d| d.uid == uid)); + match state { + Some(state) => json!({ + "ok": true, + "result": { + "selection": state.selection.objects.iter().map(|id| id.0).collect::>(), + "key": state.selection.key.map(|id| id.0), + "tool": app.session.tool_id(), + } + }), + None => json!({"ok": false, "error": "the gesture's document is no longer open"}), + } } fn ok(v: Value) -> Outcome { @@ -443,7 +478,21 @@ 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()) { + // 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}))) + } + // `params` go to the importer File › Open picks for the file, as in `document.open`. + None => { + let mut open = json!({"path": s("path")}); + if let Some(params) = p.get("params").filter(|v| !v.is_null()) { + open["params"] = params.clone(); + } + wrap(app.run("file.open", open)) + } + }, "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 @@ -456,6 +505,11 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques o.remove("wait"); } let method = req.method.as_str(); + // The key check of `document.export` or `document.save` runs before the file is written. + let checked = if method == "app.export" { "document.export" } else { "document.save" }; + if let Err(e) = crate::menus::check_params(&app.session, checked, &p) { + return err(e); + } wrap(if wait { crate::background::in_foreground(app, |app| save_or_export(app, method, &p)) } else { save_or_export(app, method, &p) }) } // As the menu's Quit: `{"pending": "saveChanges"}` while a modified document is asked @@ -561,6 +615,7 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); let view = app.view_info(); let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); + let from = app.session.input_count(); let held = PointerEvent::json_backquote(p, false).and_then(|d| events.iter().map(|e| PointerEvent::json_backquote(e, d)).collect::, _>>()); let held = match held { @@ -601,7 +656,15 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe } } ctx.request_repaint(); - wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "key": d["key"], "tool": app.session.tool_id()}))) + // A Shape Builder merge of a large selection is computed on another thread, and the + // reply to the events that started it waits for it without holding the frames up. A + // gesture sent while it is computed is made after it, and its reply waits until then; + // a hover is answered at once. + let to = app.session.input_count(); + if !app.session.input_applied(from, to) { + return Outcome::AfterToolEdit { from, to, document: app.session.active().map(|d| d.uid) }; + } + Outcome::Done(pointer_reply(app)) } /// `ui.click` and `ui.drag`: pointer input through egui itself. diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index e587c0489..6d46e6c0f 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -1,5 +1,6 @@ use serde_json::{Value, json}; use vectorcraft_engine::Session; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; use super::{ControlRequest, MAX_DIALOG_FIELDS, Outcome, handle}; use crate::state::Dialog; @@ -11,6 +12,27 @@ fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value r } +#[test] +fn deferred_pointer_reply_uses_the_gesture_document_after_file_new() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let (reply, received) = std::sync::mpsc::channel(); + let at = app.session.input_count(); + app.pointer_replies.push(super::PendingPointerReply { from: at, to: at, document: app.session.active().map(|d| d.uid), reply }); + + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("tool.select", json!({"tool": "rectangle"})).unwrap(); + app.answer_pointer_replies(&egui::Context::default()); + + let result = received.try_recv().unwrap(); + assert_eq!(result["ok"], true, "{result}"); + assert_eq!(result["result"]["selection"], json!([id])); + assert_eq!(result["result"]["key"], Value::Null); + assert_eq!(result["result"]["tool"], "rectangle"); +} + #[test] fn control_lists_and_selects_optional_addon_tools() { let mut app = VectorcraftApp::new(Session::new(), Services::default()); @@ -338,6 +360,263 @@ fn pointer_replies_name_the_key_object() { assert_eq!(click(&mut app), Some(Value::Null), "a click on the key again lets it go"); } +/// A Shape Builder click on a large selection merges on another thread: the frames go on, and +/// the `ui.pointer` reply comes once the merge is applied, with the selection after it. +#[test] +fn pointer_reply_waits_for_a_merge_on_another_thread() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx); + app.run("file.new", json!({"width": 800, "height": 200})).unwrap(); + for k in 0..40 { + app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap(); + } + app.run("select.all", json!({})).unwrap(); + app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap(); + let ctx = egui::Context::default(); + let send = |app: &mut VectorcraftApp, events: Value| { + let (req, reply) = ControlRequest::new("ui.pointer", json!({"events": events})); + tx.send(req).unwrap(); + app.drain_control(&ctx); + reply + }; + let hover = send(&mut app, json!([{"kind": "move", "x": 15, "y": 10}])); + assert_eq!(hover.try_recv().unwrap()["ok"], true, "a hover is answered at once"); + app.session.tool_poll(app.view_info(), true).unwrap(); + let revision = app.session.active().unwrap().revision; + let click = send(&mut app, json!([{"kind": "down", "x": 15, "y": 10}, {"kind": "up", "x": 15, "y": 10}])); + assert!(click.try_recv().is_err() && app.session.tool_editing(), "the click is not answered before the merge is applied"); + // A hover sent meanwhile is answered at once; a click is made after the merge, and its + // reply waits until then. + let hover = send(&mut app, json!([{"kind": "move", "x": 35, "y": 10}])); + assert_eq!(hover.try_recv().map(|r| r["ok"].clone()), Ok(json!(true)), "a hover during the merge"); + let cmd = json!({"cmd": true}); + let press = send(&mut app, json!([{"kind": "down", "x": 500, "y": 150, "mods": cmd}, {"kind": "up", "x": 500, "y": 150, "mods": cmd}])); + assert!(press.try_recv().is_err(), "a Cmd-click during the merge waits for it"); + let hover = send(&mut app, json!([{"kind": "move", "x": 45, "y": 10}])); + assert_eq!(hover.try_recv().map(|r| r["ok"].clone()), Ok(json!(true)), "a hover after the held Cmd-click"); + let start = std::time::Instant::now(); + let reply = loop { + if let Ok(r) = click.try_recv() { + break r; + } + assert!(app.session.tool_editing(), "the merge is being computed until the reply goes"); + assert_eq!(app.session.active().unwrap().revision, revision); + assert!(start.elapsed() < std::time::Duration::from_secs(60), "no reply"); + std::thread::sleep(std::time::Duration::from_millis(2)); + // A frame: the canvas checks on the tool, then the replies are answered. + let r = app.session.tool_poll(app.view_info(), false); + crate::canvas::apply_requests(&mut app, r); + app.answer_pointer_replies(&ctx); + }; + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(reply["result"]["selection"].as_array().map(Vec::len), Some(41), "{reply}: the selection after the merge"); + assert_ne!(app.session.active().unwrap().revision, revision); + let press = loop { + if let Ok(r) = press.try_recv() { + break r; + } + assert!(start.elapsed() < std::time::Duration::from_secs(60), "no reply to the Cmd-click"); + let r = app.session.tool_poll(app.view_info(), false); + crate::canvas::apply_requests(&mut app, r); + app.answer_pointer_replies(&ctx); + }; + assert_eq!(press["result"]["selection"].as_array().map(Vec::len), Some(0), "{press}: the lent selection tool deselected after the merge"); + assert_eq!(press["result"]["tool"], "shapeBuilder", "{press}"); +} + +/// A Cmd-click held while a Shape Builder merge is computed on another thread makes the artboard +/// it lands in active once it is made, as a Cmd-click made at once does: by the frames' checks, +/// by choosing another tool first, or before a command that acts on the active artboard takes +/// it. +#[test] +fn a_held_cmd_click_makes_its_artboard_active() { + for then in ["frames", "another tool", "All on Active Artboard", "another document"] { + held_cmd_click(then); + } +} + +fn held_cmd_click(then: &str) { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + if then == "another document" { + // A document with an artboard where the press is, whose view keeps its own. + app.run("file.new", json!({"width": 1400, "height": 200})).unwrap(); + app.run("artboard.new", json!({"x": 1000, "y": 0, "width": 300, "height": 200})).unwrap(); + app.view_mut().unwrap().artboard = 0; + } + app.run("file.new", json!({"width": 800, "height": 200})).unwrap(); + app.run("artboard.new", json!({"x": 1000, "y": 0, "width": 300, "height": 200})).unwrap(); + for k in 0..40 { + app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap(); + } + app.run("select.all", json!({})).unwrap(); + app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap(); + app.view_mut().unwrap().artboard = 0; + let view = app.view_info(); + let at = |kind, x, mods| PointerEvent { kind, pos: vectorcraft_geom::Point::new(x, 10.0), mods, pressure: 1.0, backquote: false, pen: None }; + crate::canvas::dispatch(&mut app, &at(PointerKind::Move, 15.0, Mods::default()), view); + app.session.tool_poll(view, true).unwrap(); + crate::canvas::dispatch(&mut app, &at(PointerKind::Down, 15.0, Mods::default()), view); + crate::canvas::dispatch(&mut app, &at(PointerKind::Up, 15.0, Mods::default()), view); + let cmd = Mods { cmd: true, ..Default::default() }; + crate::canvas::dispatch(&mut app, &at(PointerKind::Down, 1100.0, cmd), view); + crate::canvas::dispatch(&mut app, &at(PointerKind::Up, 1100.0, cmd), view); + assert_eq!(app.view().unwrap().artboard, 0, "{then}: the Cmd-click is held"); + if then == "another tool" { + app.select_tool("rectangle"); + assert!(!app.session.tool_editing(), "{then}"); + } + if then == "All on Active Artboard" { + app.run("select.allOnArtboard", json!({})).unwrap(); + assert!(app.session.doc().unwrap().selection.is_empty(), "{then}: the second artboard, which holds no art"); + } + let doc = app.session.active_index().unwrap(); + if then == "another document" { + // A document tab chosen. + app.session.set_active(0); + } + while app.session.tool_waiting() { + let r = app.session.tool_poll(view, true); + crate::canvas::apply_requests(&mut app, r); + } + app.sync_views(); + assert_eq!(app.views[doc].artboard, 1, "{then}: the Cmd-click made the artboard it landed in active"); + if then == "another document" { + assert_eq!(app.views[0].artboard, 0, "{then}: the other document's view keeps its artboard"); + } +} + +/// A dialog chosen from the menu, or with `ui.menuDialog`, while a Shape Builder merge is +/// computed on another thread opens after the merge is applied, on the merged art. So do the +/// app's own dialogs (Recolor Artwork), from the menu or a panel's button. +#[test] +fn menu_dialogs_open_after_a_merge_on_another_thread() { + for route in ["menu", "ui.menuDialog", "app dialog from the menu", "app dialog from a panel"] { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 800, "height": 200})).unwrap(); + for k in 0..40 { + app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap(); + } + app.run("select.all", json!({})).unwrap(); + app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap(); + let view = app.view_info(); + let at = |kind| PointerEvent { + kind, + pos: vectorcraft_geom::Point::new(15.0, 10.0), + mods: Mods::default(), + pressure: 1.0, + backquote: false, + pen: None, + }; + crate::canvas::dispatch(&mut app, &at(PointerKind::Move), view); + app.session.tool_poll(view, true).unwrap(); + crate::canvas::dispatch(&mut app, &at(PointerKind::Down), view); + crate::canvas::dispatch(&mut app, &at(PointerKind::Up), view); + assert!(app.session.tool_editing(), "{route}"); + match route { + "menu" => crate::menus::invoke(&mut app, "object.move", json!({})), + "ui.menuDialog" => { + app.run("ui.menuDialog", json!({"command": "object.move"})).unwrap(); + } + "app dialog from the menu" => crate::menus::invoke(&mut app, "ui.recolorDialog", json!({})), + _ => { + app.run("ui.recolorDialog", json!({})).unwrap(); + } + } + assert!(app.ui.dialog.is_some(), "{route}: the dialog is open"); + assert!(!app.session.tool_editing(), "{route}"); + let merged = app.session.doc().unwrap().history.undo.last().map(|e| e.label.clone()); + assert_eq!(merged.as_deref(), Some("Shape Builder"), "{route}"); + assert_eq!(app.session.doc().unwrap().selection.objects.len(), 41, "{route}: the dialog acts on the merged art"); + } +} + +/// The Separations panel's checkboxes and a preference set while a Shape Builder merge is +/// computed on another thread give the documents new revisions: the merge is applied first, +/// and the document keeps it. +#[test] +fn panels_that_redraw_apply_a_merge_on_another_thread_first() { + let cases = [ + ("view.separationsPreview", json!({"on": false})), + ("view.overprintPreview", json!({"on": false})), + ("prefs.set", json!({"values": {"gridlineEvery": 90}})), + ]; + for (id, params) in cases { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 800, "height": 200})).unwrap(); + for k in 0..40 { + app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap(); + } + app.run("select.all", json!({})).unwrap(); + app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap(); + let view = app.view_info(); + let at = |kind| PointerEvent { + kind, + pos: vectorcraft_geom::Point::new(15.0, 10.0), + mods: Mods::default(), + pressure: 1.0, + backquote: false, + pen: None, + }; + crate::canvas::dispatch(&mut app, &at(PointerKind::Move), view); + app.session.tool_poll(view, true).unwrap(); + crate::canvas::dispatch(&mut app, &at(PointerKind::Down), view); + crate::canvas::dispatch(&mut app, &at(PointerKind::Up), view); + assert!(app.session.tool_editing(), "{id}"); + app.run(id, params).unwrap(); + assert!(!app.session.tool_editing(), "{id}"); + for _ in 0..3 { + app.session.tool_poll(view, false).unwrap(); + } + let merged = app.session.doc().unwrap().history.undo.last().map(|e| e.label.clone()); + assert_eq!(merged.as_deref(), Some("Shape Builder"), "{id}"); + assert_eq!(app.session.doc().unwrap().selection.objects.len(), 41, "{id}"); + } +} + +/// Saving, exporting or closing while a Shape Builder merge is computed on another thread +/// applies the merge first: the file holds it, and closing asks to save it. +#[test] +fn save_and_close_apply_a_merge_on_another_thread_first() { + let dir = vectorcraft_testkit::temp_dir("control-merge-save"); + for then in ["file.saveAs", "file.exportAs", "file.close"] { + let services = Services { write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), ..Default::default() }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 800, "height": 200})).unwrap(); + for k in 0..40 { + app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap(); + } + app.run("select.all", json!({})).unwrap(); + app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap(); + // As if just saved: only the merge modifies it. + app.session.doc_mut().unwrap().mark_saved(); + let view = app.view_info(); + let at = |kind| PointerEvent { + kind, + pos: vectorcraft_geom::Point::new(15.0, 10.0), + mods: Mods::default(), + pressure: 1.0, + backquote: false, + pen: None, + }; + crate::canvas::dispatch(&mut app, &at(PointerKind::Move), view); + app.session.tool_poll(view, true).unwrap(); + crate::canvas::dispatch(&mut app, &at(PointerKind::Down), view); + crate::canvas::dispatch(&mut app, &at(PointerKind::Up), view); + assert!(app.session.tool_editing() && !app.session.doc().unwrap().is_dirty(), "{then}"); + let path = dir.join(if then == "file.exportAs" { "a.svg" } else { "a.vectorcraft" }); + let p = if then == "file.close" { json!({}) } else { json!({"path": path.to_string_lossy()}) }; + let r = app.run(then, p).unwrap(); + assert!(!app.session.tool_editing(), "{then}"); + let merged = app.session.doc().unwrap().history.undo.last().map(|e| e.label.clone()); + assert_eq!(merged.as_deref(), Some("Shape Builder"), "{then}"); + match then { + "file.saveAs" => assert!(!app.session.doc().unwrap().is_dirty(), "the file holds the merge"), + "file.exportAs" => assert!(app.session.doc().unwrap().is_dirty(), "an export leaves the document modified"), + _ => assert_eq!(r["pending"], "saveChanges", "closing asks to save the merge"), + } + } +} + /// `ui.dialog.set` of a field the open dialog does not have is an error that lists its fields, /// and the dialog stays as it was. Fields a dialog may not hold yet are still set: `discard` /// with a discard button, `preview` with a live preview, `__` fields and the spec's `inputs`. diff --git a/crates/ui-egui/src/custom_toolbars.rs b/crates/ui-egui/src/custom_toolbars.rs index adbf1223f..e5008644b 100644 --- a/crates/ui-egui/src/custom_toolbars.rs +++ b/crates/ui-egui/src/custom_toolbars.rs @@ -523,12 +523,17 @@ pub fn sync(ui: &UiState) { } } -/// Window › Toolbars: Advanced and Double Column, the toolbars made with New Toolbar (each checked -/// while it shows), New Toolbar… and Manage Toolbars…. +/// Window › Toolbars: Advanced, Double Column and Keep Tools Beside the Canvas, the toolbars made +/// with New Toolbar (each checked while it shows), New Toolbar… and Manage Toolbars…. pub fn menu_items() -> Vec { use crate::menus::Item; let c = |label: &'static str, id: &'static str| Item::Cmd(label, id, Value::Null); - let mut v = vec![c("Advanced", "window.toolbarAdvanced"), c("Double Column", "window.toolbarColumns"), Item::Sep]; + let mut v = vec![ + c("Advanced", "window.toolbarAdvanced"), + c("Double Column", "window.toolbarColumns"), + c("Keep Tools Beside the Canvas", "window.toolsBesideCanvas"), + Item::Sep, + ]; let names = names_store().read().map(|v| v.clone()).unwrap_or_default(); if !names.is_empty() { v.extend(names.into_iter().map(|n| Item::Cmd(n, "window.toolbar.custom", json!({ "name": n })))); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a7766dd8d..1f59647c1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -17,11 +17,13 @@ pub mod blend_options; mod brush_art_options; pub mod brush_delete; pub mod brush_options; +pub mod bullets_numbering; pub mod char_style_options; pub mod color_balance; pub mod color_guide_options; mod color_picker; mod command; +pub mod composite_fonts; pub mod confirm; pub mod corners; pub mod dimension; @@ -96,6 +98,7 @@ pub mod relink; pub mod rename_artboards; pub(crate) mod revolve; pub(crate) mod revolve_gizmo; +pub mod safe_mode; pub mod saturate; pub mod save_brush_library; mod save_changes; @@ -113,6 +116,7 @@ pub(crate) mod simplify_slider; pub mod slices; pub(crate) mod smooth_slider; pub mod spot_colors; +pub mod svg_filter; pub(crate) mod svg_options; pub mod swatch_conflict; pub mod swatch_options; @@ -122,6 +126,7 @@ pub mod symbolism_options; pub mod tate_chu_yoko; mod text_export; pub mod text_import; +mod three_d_classic; mod tiff_bmp_tga; pub mod tile_edge_color; mod tools; @@ -139,6 +144,7 @@ pub use document_setup::open as open_document_setup; pub use effect::open as open_effect_dialog; pub(crate) use effect::{FILM_GRAIN_FIELDS, PAINT_DAUB_BRUSH_TYPE_LABELS, SHADES, shown_choices}; pub use export_as::open as open_export_as; +pub(crate) use export_for_screens::drop_unread_screen_settings; pub use export_for_screens::open as open_export_for_screens; pub(crate) use export_for_screens::{ KIND as EXPORT_FOR_SCREENS, PresetPick as ScreenPresetPick, export_preset as screen_export_preset, formats as screen_formats, @@ -345,10 +351,13 @@ registry! { Corners: [corners::KIND] => corners::SPEC, Hyphenation: [hyphenation::KIND] => hyphenation::SPEC, Justification: [justification::KIND] => justification::SPEC, + BulletsAndNumbering: [bullets_numbering::KIND] => bullets_numbering::SPEC, TateChuYokoSettings: [tate_chu_yoko::KIND] => tate_chu_yoko::SPEC, + CompositeFonts: [composite_fonts::KIND] => composite_fonts::SPEC, SavePdf: [save_pdf::KIND] => save_pdf::SPEC, SvgOptions: [svg_options::KIND] => svg_options::SPEC, JavaScriptFiles: [javascript_files::KIND] => javascript_files::SPEC, + SvgFilter: [svg_filter::KIND] => svg_filter::SPEC, NewDocumentMore: [new_document::MORE] => new_document::MORE_SPEC, Place: [place::KIND] => place::SPEC, RasterOptions: ["pngOptions", "jpgOptions", "webpOptions", "gifOptions", "png8Options"] => png_options::SPEC, @@ -381,6 +390,8 @@ registry! { DivideSlices: [slices::DIVIDE] => slices::DIVIDE_SPEC, EpsOptions: [eps_options::KIND] => eps_options::SPEC, Recovery: [crate::recovery::KIND] => recovery::SPEC, + PluginStopped: [safe_mode::STOPPED] => safe_mode::STOPPED_SPEC, + SafeMode: [safe_mode::KIND] => safe_mode::SPEC, DxfImport: [dxf_import::KIND] => dxf_import::SPEC, RasterFormatOptions: ["tiffOptions", "bmpOptions", "tgaOptions"] => png_options::SPEC, SaveForWeb: [save_for_web::KIND] => save_for_web::SPEC, @@ -390,6 +401,8 @@ 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, + PluginInstall: [plugin::INSTALL_KIND] => plugin::INSTALL_SPEC, ScriptCheck: [script_check::KIND] => script_check::SPEC, VectorHalftone: [halftone::KIND] => halftone::SPEC, SimplifySlider: [simplify_slider::KIND] => simplify_slider::SPEC, @@ -473,7 +486,8 @@ pub fn accepts_field(d: &Dialog, field: &str) -> bool { /// Pattern Brush Options corner tile set this way is saved by OK, as one chosen from its menu is, /// an Edit Data Set field set this way is no longer the row's empty text value, and a Layer /// Options setting set this way for several rows is sent to every row (Template also sets Lock -/// and Dim Images, as its checkbox does). +/// and Dim Images, as its checkbox does). A DXF Options preset set this way sets its fields, as +/// one picked from its menu does, and the preset it shows follows the DXF options set this way. pub fn set_field(d: &mut Dialog, field: String, value: Value) { if d.kind == brush_options::KIND { brush_art_options::note_set(d, &field); @@ -484,7 +498,11 @@ pub fn set_field(d: &mut Dialog, field: String, value: Value) { if d.kind == layer_options::KIND { layer_options::note_set(d, &field, &value); } + let dxf = (d.kind == dxf_options::KIND).then(|| field.clone()); d.fields.insert(field, value); + if let Some(field) = dxf { + dxf_options::note_set(d, &field); + } } /// The fields `ui.dialog.set` can set on `d`, sorted, without the `__` ones: those it holds, @@ -546,6 +564,12 @@ pub fn cancel(app: &mut VectorcraftApp) { if d.kind == swatch_conflict::KIND { crate::placing::cancel_swatch_paste(app); } + if d.kind == plugin::INSTALL_KIND { + app.pending_plugin_install = None; + } + if d.kind == safe_mode::STOPPED { + safe_mode::cancel_stopped(app); + } // Toolbar-only Flare options start no preview and must not cancel a gesture // that was already active before opening them. The generic parameter dialog has a spec // without a preview, yet previews some commands (Grid Repeat Options) when asked to. @@ -587,13 +611,15 @@ pub fn confirm(app: &mut VectorcraftApp) -> DialogResult { /// After a dialog closes, and each frame: a Missing Fonts dialog that another dialog replaced waits /// for its turn again, the search a Missing Fonts or Find Linked Files dialog started stops once -/// that dialog is gone, and with no dialog open the next document's Missing Fonts dialog opens -/// (one dialog at a time). +/// that dialog is gone, and with no dialog open the choice about a stopped plug-in that another +/// dialog replaced opens again, else the next document's Missing Fonts dialog (one dialog at a +/// time). pub(crate) fn settle(app: &mut VectorcraftApp) { crate::placing::settle_swatch_paste(app); missing_fonts::requeue_replaced(app); missing_fonts::stop_when_closed(app); find_links::stop_when_closed(app); + safe_mode::reopen(app); missing_fonts::open_next(app); } @@ -805,6 +831,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/action_batch.rs b/crates/ui-egui/src/dialogs/action_batch.rs index 0d6382e9a..4e8a3bfae 100644 --- a/crates/ui-egui/src/dialogs/action_batch.rs +++ b/crates/ui-egui/src/dialogs/action_batch.rs @@ -92,9 +92,23 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { form::check(ui, d, "overrideSave", "Override Action \"Save\" Commands"); form::check(ui, d, "overrideExport", "Override Action \"Export\" Commands"); form::check(ui, d, "stopOnError", "Stop on Errors"); + if overwrites_one_file(app, d) { + ui.add_space(6.0); + ui.label(egui::RichText::new(tl!("Each data set saves over the same file, which keeps only the last data set.")).size(11.5).color(t.warning)); + } false } +/// Whether the batch the dialog would run saves every data set over the same file +/// ([`vectorcraft_engine::cmd::fileio::batch_overwrites_one_file`]). +fn overwrites_one_file(app: &VectorcraftApp, d: &Dialog) -> bool { + let set = d.fields.get("set").and_then(Value::as_u64).unwrap_or(0) as usize; + let action = d.fields.get("action").and_then(Value::as_u64).unwrap_or(0) as usize; + let steps = app.ui.action_sets.get(set).and_then(|s| s.actions.get(action)).map_or(&[][..], |a| &a.steps[..]); + let commands = steps.iter().map(|(c, p)| json!({"command": c, "params": p})).collect(); + vectorcraft_engine::cmd::fileio::batch_overwrites_one_file(&app.session, ¶ms(d, "", commands)) +} + fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let set = d.fields.get("set").and_then(Value::as_u64).unwrap_or(0) as usize; let action = d.fields.get("action").and_then(Value::as_u64).unwrap_or(0) as usize; @@ -161,6 +175,18 @@ mod tests { use vectorcraft_engine::Session; fn shown(source: &str) -> Vec { + let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test")); + d.fields["source"] = json!(source); + render(&mut d) + } + + /// Draws `d` once and returns the texts it shows. + fn render(d: &mut Dialog) -> Vec { + render_in(&mut VectorcraftApp::new(Session::new(), Default::default()), d) + } + + /// Draws `d` once over `app` and returns the texts it shows. + fn render_in(app: &mut VectorcraftApp, d: &mut Dialog) -> Vec { fn collect(shape: &egui::Shape, out: &mut Vec) { match shape { egui::Shape::Text(t) => out.push(t.galley.text().to_string()), @@ -168,13 +194,10 @@ mod tests { _ => {} } } - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test")); - d.fields["source"] = json!(source); let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); let mut frame = ctx.run_ui(Default::default(), |ui| { - body(&mut app, ui, &mut d); + body(app, ui, d); }); frame.textures_delta.clear(); let mut labels = vec![]; @@ -220,4 +243,37 @@ mod tests { d.fields["source"] = json!("folder"); assert!(params(&d, "Test", vec![]).get("datasetFileNaming").is_none()); } + + const ONE_FILE: &str = "Each data set saves over the same file, which keeps only the last data set."; + + #[test] + fn data_sets_offer_save_and_close_with_a_warning() { + let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test")); + d.fields["destination"] = json!("saveAndClose"); + let labels = render(&mut d); + assert!(labels.iter().any(|label| label == "Save and Close"), "{labels:?}"); + assert!(!labels.iter().any(|label| label == ONE_FILE), "a folder source opens a file per pass: {labels:?}"); + d.fields["source"] = json!("datasets"); + let labels = render(&mut d); + assert!(labels.iter().any(|label| label == "Save and Close"), "{labels:?}"); + assert!(labels.iter().any(|label| label == ONE_FILE), "{labels:?}"); + assert_eq!(params(&d, "Test", vec![])["destination"], "saveAndClose"); + d.fields["destination"] = json!("folder"); + assert!(!render(&mut d).iter().any(|label| label == ONE_FILE)); + } + + #[test] + fn data_sets_warn_about_a_recorded_save_left_in_place() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.ui.action_sets[0].actions[0].steps.push(("document.save".into(), json!({"path": "/x/a.vectorcraft"}))); + let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test")); + d.fields["source"] = json!("datasets"); + d.fields["destinationFolder"] = json!("/out"); + assert!(!render_in(&mut app, &mut d).iter().any(|label| label == ONE_FILE), "Override Save points the save into the folder"); + d.fields["overrideSave"] = json!(false); + assert!(render_in(&mut app, &mut d).iter().any(|label| label == ONE_FILE)); + d.fields["overrideSave"] = json!(true); + d.fields["destination"] = json!("none"); + assert!(render_in(&mut app, &mut d).iter().any(|label| label == ONE_FILE)); + } } diff --git a/crates/ui-egui/src/dialogs/bullets_numbering.rs b/crates/ui-egui/src/dialogs/bullets_numbering.rs new file mode 100644 index 000000000..43f15edbb --- /dev/null +++ b/crates/ui-egui/src/dialogs/bullets_numbering.rs @@ -0,0 +1,802 @@ +//! More Options at the end of the Paragraph panel's Bullets and Numbered List presets (or `ui.bulletsAndNumbering {tab?}`): a +//! Bullets tab and a Numbers tab, each with the Level field and the presets, and the Position group +//! (Alignment, Left Indent and First Line Indent) of the paragraphs the Paragraph panel acts on. +//! The dialog opens on the values the Paragraph panel shows, with no preset picked and a field +//! blank where the paragraphs differ. Each change shows on the type while the dialog is open. OK +//! runs `text.setFormat` with the values that changed, and with the indents it shows when a preset +//! puts the paragraphs in a list, as one undo step; Cancel puts the type back as it was. A preset +//! picked for paragraphs that are in no list and have no indents fills in the 18 pt and -18 pt +//! indents `text.setFormat` gives them, and None takes them out again. +//! +//! Fields: `tab` (`bullets` or `numbers`), `list` (`none`, `bullet` or `number`: the kind of the +//! preset picked), `bullet` (`dot`, `dash`, `circle` or `square`), `number` (`arabic`, +//! `upperLetters`, `lowerLetters`, `upperRoman`, `lowerRoman`, `twoDigits` or `threeDigits`), +//! `listAlign` (`left` or `right`), `listLevel` (1 to 9; 1 for paragraphs in no list), +//! `leftIndent` and `firstLineIndent` (pt), as `text.setFormat` takes them, with `__initial` (the +//! values it opened with) and `__targets` (the type and range it acts on). +//! +//! Behavior reference (public documentation): +//! . + +use serde_json::{Map, Value, json}; +use vectorcraft_doc::text::{LIST_HANGING_INDENT, LIST_LEVELS, NumberStyle, ParaList}; + +use super::swatch_options::label; +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Bullets and Numbering. +pub const KIND: &str = "bulletsAndNumbering"; + +/// The field holding the values the dialog opened with. +const INITIAL: &str = "__initial"; +/// The field holding the `ids`, `start` and `end` the dialog's `text.setFormat` acts on. +const TARGETS: &str = "__targets"; +/// Set while the indent fields hold the hanging indent a preset filled in ([`pick`]). +const HANGING: &str = "__hanging"; +const CMD: &str = "text.setFormat"; + +/// The `tab` values, in the order the tabs are shown. +const TABS: [&str; 2] = ["bullets", "numbers"]; +/// The `listAlign` values, in the order Alignment lists them. +const ALIGNS: [&str; 2] = ["left", "right"]; +/// The indents `text.setFormat` keeps (points): a value typed outside them becomes the nearer end. +const INDENTS: std::ops::RangeInclusive = -1296.0..=1296.0; +/// The side of a preset button (points). +const PRESET: f32 = 34.0; +/// The keys a preset button with the keyboard focus keeps from egui's focus moves. +const ARROWS: egui::EventFilter = egui::EventFilter { tab: false, horizontal_arrows: true, vertical_arrows: false, escape: false }; + +/// The fields `ui.dialog.set` can set while the dialog does not hold them: the dialog leaves a +/// field out where the paragraphs differ, and holds no `bullet` or `number` for paragraphs in no +/// list or in another kind of list. +const INPUTS: [&str; 7] = ["list", "bullet", "number", "listAlign", "listLevel", "leftIndent", "firstLineIndent"]; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Bullets and Numbering").into(), + body, + confirm, + preview: true, + min_width: 380.0, + inputs: |_| &INPUTS, + ..DialogSpec::FORM +}; + +/// The kind of list `list` is, as the `list` field names it. +fn kind(list: Option) -> &'static str { + match list { + None => "none", + Some(l) if l.number.is_some() => "number", + Some(_) => "bullet", + } +} + +/// Open Bullets and Numbering on the paragraph attributes the Paragraph panel shows, on tab `tab` +/// (`bullets` or `numbers`; without it, Numbers when the paragraphs are a numbered list). +pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { + use crate::panels::paragraph::{shared_para, shared_para_field}; + let key = "tab"; + let tab = match p.get(key) { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_str().filter(|t| TABS.contains(t)).ok_or_else(|| format!("`{key}` must be one of {}", TABS.join(", ")))?), + }; + crate::panels::character::text_style(app).ok_or("select some type first")?; + // While typing, the dialog acts on the paragraphs of the text being edited. + let mut targets = json!({}); + if let Some((id, a, b)) = crate::panels::character::text_editing(app) { + crate::panels::character::end_typing(app); + targets = json!({"ids": [id.0], "start": a, "end": b}); + } + let mut values = Map::new(); + let list = shared_para(app, |p| kind(p.list)); + if let Some(k) = list { + values.insert("list".into(), json!(k)); + } + if let Some(Some(b)) = shared_para(app, |p| p.list.filter(|l| l.number.is_none()).map(|l| l.bullet)) { + values.insert("bullet".into(), json!(b)); + } + if let Some(Some(n)) = shared_para(app, |p| p.list.and_then(|l| l.number)) { + values.insert("number".into(), json!(n)); + } + // A paragraph in no list counts as aligned left, the alignment a list starts with. + if let Some(a) = shared_para(app, |p| p.list.map(|l| l.align).unwrap_or_default()) { + values.insert("listAlign".into(), json!(a)); + } + // …and the paragraphs' list level, 1 for a paragraph in no list (the level a list starts at). + if let Some(l) = shared_para(app, |p| p.list.map_or(1, |l| l.depth())) { + values.insert("listLevel".into(), json!(l)); + } + if let Some(v) = shared_para_field(app, |p| p.left_indent) { + values.insert("leftIndent".into(), json!(v)); + } + if let Some(v) = shared_para_field(app, |p| p.first_line_indent) { + values.insert("firstLineIndent".into(), json!(v)); + } + let tab = tab.unwrap_or(if list == Some("number") { "numbers" } else { "bullets" }); + let mut fields = Value::Object(values.clone()); + fields[INITIAL] = Value::Object(values); + fields[TARGETS] = targets; + fields["tab"] = json!(tab); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let unit = app.session.general_unit(); + let numbers = d.str("tab") == "numbers"; + if let Some(tab) = widgets::tab_bar(ui, &[tl!("Bullets"), tl!("Numbers")], usize::from(numbers)).and_then(|i| TABS.get(i)) { + d.fields.insert("tab".into(), json!(tab)); + } + ui.add_space(8.0); + level(ui, d); + ui.add_space(8.0); + if d.str("tab") == "numbers" { + number_tab(ui, d); + } else { + bullet_tab(ui, d); + } + ui.add_space(10.0); + position(ui, d, unit); + // The type shows the dialog's values; with nothing changed it is as it was. + let p = params(d); + if changes(&p) { + form::preview_now(app, d, tl!("Bullets and Numbering"), CMD, Value::Object(p)); + } else if d.fields.remove(form::PREVIEWED).is_some() { + let _ = app.session.cancel_interaction(); + } + false +} + +/// The Level field: the paragraphs' list level, 1 to 9, blank where they differ. +fn level(ui: &mut egui::Ui, d: &mut Dialog) { + ui.horizontal(|ui| { + label(ui, tl!("Level:")); + let value = d.fields.get("listLevel").and_then(Value::as_f64); + if let Some(v) = widgets::range_field(ui, ("bullets-numbering", "listLevel"), value, 1.0..=LIST_LEVELS as f64, "", 0, 60.0) { + d.fields.insert("listLevel".into(), json!(v)); + } + }); +} + +/// The Bullets tab: the bullet presets in a row, None first, the one the dialog applies selected. +fn bullet_tab(ui: &mut egui::Ui, d: &mut Dialog) { + let presets = crate::panels::paragraph::bullet_presets(); + let shown = d.fields.get("bullet").and_then(Value::as_str); + let current = match d.fields.get("list").and_then(Value::as_str) { + Some("none") => Some(0), + Some("bullet") => presets.iter().position(|(_, key, _)| shown == Some(*key)), + _ => None, + }; + label(ui, tl!("Bullet Character:")); + let names = presets.map(|(name, _, _)| name); + let picked = preset_row(ui, "bullets", &names, current, |painter, rect, i, color| { + crate::panels::paragraph::paint_bullet(painter, rect.center(), presets.get(i).and_then(|p| p.2), color); + }); + match picked.and_then(|i| presets.get(i)) { + Some((_, _, None)) => pick(d, "none", None), + Some((_, key, Some(_))) => pick(d, "bullet", Some(*key)), + None => {} + } +} + +/// The Numbers tab: None and the number styles in a row, each shown by its first two numbers, the +/// one the dialog applies selected. +fn number_tab(ui: &mut egui::Ui, d: &mut Dialog) { + let shown = d.fields.get("number").and_then(|v| serde_json::from_value::(v.clone()).ok()); + let current = match d.fields.get("list").and_then(Value::as_str) { + Some("none") => Some(0), + Some("number") => shown.and_then(|s| NumberStyle::ALL.iter().position(|&n| n == s)).map(|i| i + 1), + _ => None, + }; + label(ui, tl!("Number Style:")); + // Screen readers name each style by its first three numbers, as the Numbered List presets do. + let samples: Vec = NumberStyle::ALL.iter().map(|&s| crate::panels::paragraph::number_sample(s, 3)).collect(); + let names: Vec<&str> = std::iter::once(tl!("None")).chain(samples.iter().map(String::as_str)).collect(); + let picked = preset_row(ui, "numbers", &names, current, |painter, rect, i, color| match i.checked_sub(1).and_then(|k| NumberStyle::ALL.get(k)) { + None => crate::panels::paragraph::paint_bullet(painter, rect.center(), None, color), + Some(&style) => { + let first = format!("{} {}", style.format(1), style.format(2)); + painter.text(rect.center(), egui::Align2::CENTER_CENTER, first, egui::FontId::proportional(11.0), color); + } + }); + match picked { + Some(0) => pick(d, "none", None), + Some(i) => { + if let Some(key) = NumberStyle::ALL.get(i - 1).and_then(|s| serde_json::to_value(s).ok()).and_then(|v| v.as_str().map(str::to_string)) { + pick(d, "number", Some(&key)); + } + } + None => {} + } +} + +/// A row of preset buttons named `names`, `current` selected, each drawn by `paint` (painter, +/// button, index, color). ← and → move the choice along the row from the button with the keyboard +/// focus, and the focus with it. Returns the button clicked or moved to. +fn preset_row( + ui: &mut egui::Ui, + salt: &str, + names: &[&str], + current: Option, + paint: impl Fn(&egui::Painter, egui::Rect, usize, egui::Color32), +) -> Option { + let t = Tokens::get(ui.ctx()); + let ids: Vec = (0..names.len()).map(|i| egui::Id::unique(("bullets-numbering-preset", salt, i))).collect(); + let mut picked = None; + let mut focused = None; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (i, (&name, &id)) in names.iter().zip(&ids).enumerate() { + let (rect, _) = ui.allocate_exact_size(egui::vec2(PRESET, PRESET), egui::Sense::hover()); + let resp = ui.interact(rect, id, egui::Sense::click()); + let selected = current == Some(i); + let fill = if selected { + t.row_selected + } else if resp.hovered() { + t.hover + } else { + t.input + }; + ui.painter().rect_filled(rect, egui::CornerRadius::same(3), fill); + let (width, border) = if selected || resp.has_focus() { (1.5, t.accent) } else { (1.0, t.input_border) }; + ui.painter().rect_stroke(rect, egui::CornerRadius::same(3), egui::Stroke::new(width, border), egui::StrokeKind::Inside); + paint(ui.painter(), rect, i, t.text_strong); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), selected, name)); + if resp.has_focus() { + focused = Some(i); + // ← and → move the choice, not egui's focus. + ui.memory_mut(|m| m.set_focus_lock_filter(id, ARROWS)); + } + if resp.on_hover_text(name).clicked() { + picked = Some(i); + } + } + }); + if let Some(i) = focused { + let (back, on) = ui.input_mut(|inp| { + (inp.consume_key(egui::Modifiers::NONE, egui::Key::ArrowLeft), inp.consume_key(egui::Modifiers::NONE, egui::Key::ArrowRight)) + }); + let to = if back { + i.checked_sub(1) + } else if on { + Some(i + 1).filter(|&j| j < names.len()) + } else { + None + }; + if back || on { + // egui moves the focus with an arrow pressed on a button that has just taken the + // focus, before its focus-lock filter applies: the row moves it instead. + ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + if let Some(j) = to { + ui.memory_mut(|m| m.request_focus(ids[j])); + picked = Some(j); + } + } + picked +} + +/// Pick a preset of kind `list` (`none`, `bullet` or `number`) with key `key` (for `bullet` or +/// `number`). For paragraphs that were in no list and had no indents, the indent fields take the +/// hanging indent `text.setFormat` gives a paragraph that joins a list; None puts them back while +/// they still hold it. +fn pick(d: &mut Dialog, list: &str, key: Option<&str>) { + d.fields.insert("list".into(), json!(list)); + if let Some(key) = key { + d.fields.insert(list.into(), json!(key)); + } + let initial = d.fields.get(INITIAL).cloned().unwrap_or_default(); + let was = |k: &str| initial.get(k).and_then(Value::as_f64); + let now = |d: &Dialog| (d.fields.get("leftIndent").and_then(Value::as_f64), d.fields.get("firstLineIndent").and_then(Value::as_f64)); + let hanging = (Some(LIST_HANGING_INDENT), Some(-LIST_HANGING_INDENT)); + if list != "none" { + let unlisted = initial.get("list").and_then(Value::as_str) == Some("none"); + if unlisted && (was("leftIndent"), was("firstLineIndent")) == (Some(0.0), Some(0.0)) && now(d) == (Some(0.0), Some(0.0)) { + d.fields.insert("leftIndent".into(), json!(LIST_HANGING_INDENT)); + d.fields.insert("firstLineIndent".into(), json!(-LIST_HANGING_INDENT)); + d.fields.insert(HANGING.into(), json!(true)); + } + } else if d.fields.remove(HANGING).is_some() && now(d) == hanging { + d.fields.insert("leftIndent".into(), json!(0.0)); + d.fields.insert("firstLineIndent".into(), json!(0.0)); + } +} + +/// The Position group: Alignment, Left Indent and First Line Indent. +fn position(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit) { + widgets::section_header(ui, tl!("Position")); + egui::Grid::new("bullets-numbering-position").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { + label(ui, tl!("Alignment:")); + let names = [tl!("Left"), tl!("Right")]; + let current = d.fields.get("listAlign").and_then(Value::as_str).and_then(|a| ALIGNS.iter().position(|x| *x == a)); + // Already translated: shown as they are. + if let Some(i) = widgets::dropdown_names(ui, ("bullets-numbering", "listAlign"), current.map_or("", |i| names[i]), &names, 100.0) + && let Some(a) = ALIGNS.get(i) + { + d.fields.insert("listAlign".into(), json!(a)); + } + ui.end_row(); + label(ui, tl!("Left Indent:")); + form::length_within(ui, d, "leftIndent", unit, 90.0, Some(INDENTS)); + ui.end_row(); + label(ui, tl!("First Line Indent:")); + form::length_within(ui, d, "firstLineIndent", unit, 90.0, Some(INDENTS)); + ui.end_row(); + }); +} + +/// `text.setFormat` parameters: the fields that differ from the values the dialog opened with, +/// on the dialog's type and range. With a preset that puts the paragraphs in a list, the indents +/// the dialog shows go too, so that the hanging indent `text.setFormat` gives a paragraph joining a +/// list does not replace them. Alignment and Level go only when the paragraphs are in a list or +/// the preset puts them in one. +fn params(d: &Dialog) -> Map { + let initial = d.fields.get(INITIAL).and_then(Value::as_object); + let was = |key: &str| initial.and_then(|m| m.get(key)).and_then(Value::as_str); + let field = |key: &str| d.fields.get(key).and_then(Value::as_str); + let mut p = d.fields.get(TARGETS).and_then(Value::as_object).cloned().unwrap_or_default(); + match field("list") { + Some("none") if was("list") != Some("none") => { + p.insert("list".into(), json!("none")); + } + Some(kind @ ("bullet" | "number")) => match field(kind) { + Some(key) if was("list") != Some(kind) || was(kind) != Some(key) => { + p.insert(kind.into(), json!(key)); + } + None if was("list") != Some(kind) => { + p.insert("list".into(), json!(kind)); + } + _ => {} + }, + _ => {} + } + let listing = ["bullet", "number"].iter().any(|k| p.contains_key(*k)) || p.get("list").is_some_and(|l| l.as_str() != Some("none")); + // `text.setFormat` sets `listAlign` only on paragraphs in a list. + if let Some(a) = field("listAlign") + && was("listAlign") != Some(a) + && (listing || was("list") != Some("none")) + { + p.insert("listAlign".into(), json!(a)); + } + // …and `listLevel` too. + if let Some(l) = d.fields.get("listLevel").and_then(Value::as_f64) + && initial.and_then(|m| m.get("listLevel")).and_then(Value::as_f64) != Some(l) + && (listing || was("list") != Some("none")) + { + p.insert("listLevel".into(), json!(l)); + } + // For paragraphs in a list and paragraphs in none, an indent left as it was is not sent: + // `text.setFormat` then gives each paragraph that joins the list its hanging indent. + let mixed = initial.is_some_and(|m| !m.contains_key("list")); + for key in ["leftIndent", "firstLineIndent"] { + if !d.fields.contains_key(key) { + continue; + } + // A length typed with its unit ("6 mm") is sent in points. + let v = d.f64(key, 0.0); + let changed = initial.and_then(|m| m.get(key)).and_then(Value::as_f64) != Some(v); + if changed || (listing && !mixed) { + p.insert(key.into(), json!(v)); + } + } + p +} + +/// Does `p` change anything (more than the targets)? +fn changes(p: &Map) -> bool { + p.keys().any(|k| !matches!(k.as_str(), "ids" | "start" | "end")) +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let p = params(d); + if !changes(&p) { + if app.session.in_interaction() { + let _ = app.session.cancel_interaction(); + } + app.ui.dialog = None; + return Ok(Value::Null); + } + form::commit_preview(app, CMD, Value::Object(p)) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_doc::text::{Bullet, ListAlign}; + use vectorcraft_engine::Session; + use vectorcraft_engine::doc::{NodeId, NodeKind, ParaStyle}; + + /// A selected area type object with two paragraphs. + fn app() -> (VectorcraftApp, u64) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + let id = app.run("text.create", json!({"x": 10, "y": 10, "text": "One\nTwo", "area": {"width": 200, "height": 100}})).unwrap()["id"] + .as_u64() + .unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + (app, id) + } + + fn paras(app: &VectorcraftApp, id: u64) -> (ParaStyle, ParaStyle) { + match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (t.para_at(0).clone(), t.para_at(1).clone()), + _ => panic!("not text"), + } + } + + /// The first paragraph's list and indents. + fn first(app: &VectorcraftApp, id: u64) -> (Option, f64, f64) { + let p = paras(app, id).0; + (p.list, p.left_indent, p.first_line_indent) + } + + fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + fn set(app: &mut VectorcraftApp, key: &str, v: Value) { + app.ui.dialog.as_mut().unwrap().fields.insert(key.into(), v); + } + + fn pick_preset(app: &mut VectorcraftApp, list: &str, key: Option<&str>) { + pick(app.ui.dialog.as_mut().unwrap(), list, key); + } + + /// `ui.bulletsAndNumbering` opens on the selected type's values; a preset, Alignment and the + /// indents show on the type at once, and OK keeps them as one `text.setFormat`, which one Undo + /// takes back. + #[test] + fn ok_applies_the_marker_alignment_and_indents_as_one_undo_step() { + let (mut app, id) = app(); + let undo = app.session.active().unwrap().history.undo.len(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + let d = app.ui.dialog.clone().expect("Bullets and Numbering opened"); + assert_eq!((d.kind.as_str(), d.str("tab"), d.str("list"), d.str("listAlign")), (KIND, "bullets".into(), "none".into(), "left".into())); + assert_eq!((d.f64("leftIndent", 1.0), d.f64("firstLineIndent", 1.0)), (0.0, 0.0)); + pick_preset(&mut app, "bullet", Some("square")); + set(&mut app, "listAlign", json!("right")); + set(&mut app, "leftIndent", json!(24)); + set(&mut app, "firstLineIndent", json!(-12)); + frame(&mut app); + assert!(app.session.in_interaction(), "the preview runs"); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "the preview adds no undo step"); + let want = Some(ParaList::bulleted(Bullet::Square).aligned(ListAlign::Right)); + assert_eq!(first(&app, id), (want, 24.0, -12.0), "previewed on the type"); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); + assert_eq!((first(&app, id), paras(&app, id).1.list), ((want, 24.0, -12.0), want)); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "one undo step"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(first(&app, id), (None, 0.0, 0.0)); + // The Numbers tab's presets make a numbered list, keeping the alignment the dialog shows. + app.run("text.setFormat", json!({"list": "bullet", "listAlign": "right"})).unwrap(); + app.run("ui.bulletsAndNumbering", json!({"tab": "numbers"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("tab"), "numbers"); + pick_preset(&mut app, "number", Some("upperRoman")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(first(&app, id).0, Some(ParaList::numbered(NumberStyle::UpperRoman).aligned(ListAlign::Right))); + // OK with nothing changed adds no undo step, and an unknown tab is an error. + let undo = app.session.active().unwrap().history.undo.len(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("tab"), "numbers", "a numbered list opens on Numbers"); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); + assert!(app.run("ui.bulletsAndNumbering", json!({"tab": "levels"})).is_err()); + assert!(app.ui.dialog.is_none()); + app.run("select.set", json!({"ids": []})).unwrap(); + assert!(app.run("ui.bulletsAndNumbering", json!({})).is_err(), "no type selected"); + } + + /// Cancel puts back what the preview changed: the list, its alignment and the indents. + #[test] + fn cancel_puts_the_type_back_as_it_was() { + let (mut app, id) = app(); + app.run("text.setFormat", json!({"number": "arabic"})).unwrap(); + let before = first(&app, id); + assert_eq!(before, (Some(ParaList::numbered(NumberStyle::Arabic)), 18.0, -18.0)); + let undo = app.session.active().unwrap().history.undo.len(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + pick_preset(&mut app, "number", Some("lowerLetters")); + set(&mut app, "listAlign", json!("right")); + set(&mut app, "leftIndent", json!(30)); + frame(&mut app); + assert_eq!(first(&app, id), (Some(ParaList::numbered(NumberStyle::LowerLetters).aligned(ListAlign::Right)), 30.0, -18.0)); + // Another change previews again, still without an undo step. + set(&mut app, "leftIndent", json!(36)); + frame(&mut app); + assert_eq!(first(&app, id).1, 36.0); + set(&mut app, "leftIndent", json!(30)); + frame(&mut app); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "the preview adds no undo step"); + // Changed back in the dialog, the type is as it was. + set(&mut app, "listAlign", json!("left")); + set(&mut app, "leftIndent", json!(18)); + pick_preset(&mut app, "number", Some("arabic")); + frame(&mut app); + assert!(!app.session.in_interaction(), "no preview while nothing changes"); + assert_eq!(first(&app, id), before); + pick_preset(&mut app, "none", None); + frame(&mut app); + assert_eq!(first(&app, id).0, None, "None previews the list off"); + super::super::cancel(&mut app); + assert!(app.ui.dialog.is_none()); + assert_eq!(first(&app, id), before); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "Cancel leaves no undo step"); + } + + /// A preset picked for paragraphs in no list and without indents fills in the 18 pt and + /// -18 pt hanging indent, and None takes it out again; indents that are set stay, and so do + /// indents set back to 0 with the preset. + #[test] + fn a_preset_fills_in_the_hanging_indent_a_new_list_gets() { + let (mut app, id) = app(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + pick_preset(&mut app, "bullet", Some("dot")); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.f64("leftIndent", 0.0), d.f64("firstLineIndent", 0.0)), (18.0, -18.0)); + pick_preset(&mut app, "number", Some("arabic")); + pick_preset(&mut app, "none", None); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.f64("leftIndent", 1.0), d.f64("firstLineIndent", 1.0)), (0.0, 0.0)); + assert!(!changes(¶ms(&d))); + super::super::cancel(&mut app); + app.run("text.setFormat", json!({"leftIndent": 10})).unwrap(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + pick_preset(&mut app, "bullet", Some("dash")); + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("leftIndent", 0.0), 10.0); + super::super::confirm(&mut app).unwrap(); + assert_eq!(first(&app, id), (Some(ParaList::bulleted(Bullet::Dash)), 10.0, 0.0)); + // Indents set back to 0 with the preset are kept: the hanging indent does not replace them. + let (mut app, id) = self::app(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + pick_preset(&mut app, "number", Some("arabic")); + set(&mut app, "leftIndent", json!(0)); + set(&mut app, "firstLineIndent", json!(0)); + frame(&mut app); + assert_eq!(first(&app, id), (Some(ParaList::numbered(NumberStyle::Arabic)), 0.0, 0.0), "previewed as shown"); + super::super::confirm(&mut app).unwrap(); + assert_eq!(first(&app, id), (Some(ParaList::numbered(NumberStyle::Arabic)), 0.0, 0.0)); + } + + /// Paragraphs that differ leave the preset unpicked and the fields blank, and OK sends only + /// what was changed: each paragraph keeps its own indents and list. + #[test] + fn differing_paragraphs_show_blank_fields_and_keep_what_is_left_alone() { + let (mut app, id) = app(); + app.run("text.setFormat", json!({"ids": [id], "bullet": "circle", "leftIndent": 30, "start": 0, "end": 0})).unwrap(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + for key in ["list", "bullet", "leftIndent"] { + assert!(!d.fields.contains_key(key), "{key} is blank"); + } + assert_eq!((d.str("listAlign"), d.f64("firstLineIndent", 1.0)), ("left".into(), 0.0)); + set(&mut app, "listAlign", json!("right")); + super::super::confirm(&mut app).unwrap(); + let (one, two) = paras(&app, id); + assert_eq!((one.list, one.left_indent), (Some(ParaList::bulleted(Bullet::Circle).aligned(ListAlign::Right)), 30.0)); + assert_eq!((two.list, two.left_indent), (None, 0.0), "the paragraph in no list stays out of one"); + } + + /// A preset picked for a list paragraph and a paragraph in no list, both without indents, gives + /// the paragraph that joins the list the hanging indent, and the list paragraph keeps its indents. + #[test] + fn a_preset_for_listed_and_unlisted_paragraphs_indents_the_one_joining_the_list() { + let (mut app, id) = app(); + app.run("text.setFormat", json!({"ids": [id], "bullet": "circle", "leftIndent": 0, "firstLineIndent": 0, "start": 0, "end": 0})).unwrap(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + assert!(!d.fields.contains_key("list"), "the list kinds differ"); + assert_eq!((d.f64("leftIndent", 1.0), d.f64("firstLineIndent", 1.0)), (0.0, 0.0)); + pick_preset(&mut app, "bullet", Some("dot")); + frame(&mut app); + assert_eq!(paras(&app, id).1.left_indent, 18.0, "previewed with the hanging indent"); + super::super::confirm(&mut app).unwrap(); + let (one, two) = paras(&app, id); + let dot = Some(ParaList::bulleted(Bullet::Dot)); + assert_eq!((one.list, one.left_indent, one.first_line_indent), (dot, 0.0, 0.0)); + assert_eq!((two.list, two.left_indent, two.first_line_indent), (dot, 18.0, -18.0)); + } + + /// Alignment changed for paragraphs that stay in no list sends nothing: there is no preview, + /// and OK adds no undo step. A preset picked with it takes the alignment. + #[test] + fn alignment_alone_on_paragraphs_in_no_list_adds_no_undo_step() { + let (mut app, id) = app(); + let undo = app.session.active().unwrap().history.undo.len(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + set(&mut app, "listAlign", json!("right")); + assert!(!changes(¶ms(app.ui.dialog.as_ref().unwrap()))); + frame(&mut app); + assert!(!app.session.in_interaction(), "no preview"); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "no undo step"); + assert_eq!(first(&app, id), (None, 0.0, 0.0)); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + set(&mut app, "listAlign", json!("right")); + pick_preset(&mut app, "bullet", Some("dot")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(first(&app, id).0, Some(ParaList::bulleted(Bullet::Dot).aligned(ListAlign::Right))); + } + + /// An indent typed past the 1296 pt `text.setFormat` keeps becomes 1296 pt (or -1296 pt) in the + /// field, so the field shows the indent the preview and OK give the type. + #[test] + fn an_indent_typed_out_of_range_shows_the_indent_the_type_gets() { + let (mut app, id) = app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + // Each frame returns where the label `find` was drawn. + let frame = |app: &mut VectorcraftApp, events: Vec, find: &str| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + out.shapes.iter().find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.text() == find => Some(t.galley.rect.translate(t.pos.to_vec2())), + _ => None, + }) + }; + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + frame(&mut app, vec![], ""); + // The fields' column starts after the longest label, First Line Indent's. + let x = frame(&mut app, vec![], "First Line Indent:").expect("First Line Indent is drawn").right() + 30.0; + // Click the field beside the label `name` (unless `click` is false, as for a field the Tab + // before it reached, which has the keyboard focus with its text selected), type `text` over + // it and Tab to commit it. + let type_into = |app: &mut VectorcraftApp, name: &str, text: &str, click: bool| { + let label = frame(app, vec![], name).unwrap_or_else(|| panic!("{name} is drawn")); + let at = egui::pos2(x, label.center().y); + let press = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + if click { + frame(app, vec![egui::Event::PointerMoved(at)], ""); + frame(app, vec![press(true), press(false)], ""); + } + frame(app, vec![], ""); + frame(app, vec![egui::Event::Text(text.into())], ""); + frame(app, vec![key(egui::Key::Tab)], ""); + frame(app, vec![], ""); + }; + type_into(&mut app, "Left Indent:", "2000 pt", true); + type_into(&mut app, "First Line Indent:", "-5000 pt", false); + let d = app.ui.dialog.clone().expect("the dialog stays open"); + assert_eq!((d.f64("leftIndent", 0.0), d.f64("firstLineIndent", 0.0)), (1296.0, -1296.0)); + assert_eq!(first(&app, id), (None, 1296.0, -1296.0), "previewed as shown"); + super::super::confirm(&mut app).unwrap(); + assert_eq!(first(&app, id), (None, 1296.0, -1296.0)); + } + + /// `ui.dialog.set` sets the fields the dialog does not hold: a bullet or a number for type in + /// no list, and the fields left blank where the paragraphs differ. + #[test] + fn the_control_channel_sets_fields_the_dialog_does_not_hold() { + let ctx = egui::Context::default(); + let (mut app, id) = app(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + for key in ["bullet", "number"] { + assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key(key), "{key} is not held"); + } + let fields = json!([{"field": "list", "value": "number"}, {"field": "number", "value": "upperRoman"}]); + crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"fields": fields})); + super::super::confirm(&mut app).unwrap(); + assert_eq!(first(&app, id).0, Some(ParaList::numbered(NumberStyle::UpperRoman))); + app.run("text.setFormat", json!({"ids": [id], "bullet": "circle", "leftIndent": 30, "firstLineIndent": -6, "start": 0, "end": 0})).unwrap(); + app.run("text.setFormat", json!({"ids": [id], "listAlign": "right", "listLevel": 2, "start": 0, "end": 0})).unwrap(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + let held = app.ui.dialog.as_ref().unwrap().fields.clone(); + for key in INPUTS { + assert!(!held.contains_key(key), "{key} is blank"); + } + let fields = json!([ + {"field": "list", "value": "bullet"}, + {"field": "bullet", "value": "square"}, + {"field": "listAlign", "value": "left"}, + {"field": "listLevel", "value": 3}, + {"field": "leftIndent", "value": 24}, + {"field": "firstLineIndent", "value": -12}, + ]); + crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"fields": fields})); + super::super::confirm(&mut app).unwrap(); + let (one, two) = paras(&app, id); + let square = Some(ParaList::bulleted(Bullet::Square).at_level(3)); + assert_eq!((one.list, one.left_indent, one.first_line_indent), (square, 24.0, -12.0)); + assert_eq!((two.list, two.left_indent, two.first_line_indent), (square, 24.0, -12.0)); + } + + /// The Level field opens on the paragraphs' list level (1 for paragraphs in no list, blank + /// where they differ), shows a change on the type at once, and OK sends it, with a preset or + /// alone. + #[test] + fn the_level_field_sets_the_list_level() { + let (mut app, id) = app(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("listLevel", 0.0), 1.0, "in no list: level 1"); + pick_preset(&mut app, "number", Some("arabic")); + set(&mut app, "listLevel", json!(3)); + frame(&mut app); + assert_eq!(first(&app, id).0, Some(ParaList::numbered(NumberStyle::Arabic).at_level(3)), "previewed on the type"); + super::super::confirm(&mut app).unwrap(); + assert_eq!(paras(&app, id).1.list, Some(ParaList::numbered(NumberStyle::Arabic).at_level(3))); + app.run("text.setFormat", json!({"ids": [id], "listLevel": 5, "start": 0, "end": 0})).unwrap(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key("listLevel"), "levels 5 and 3 differ: blank"); + super::super::cancel(&mut app); + app.run("text.setFormat", json!({"ids": [id], "listLevel": 5})).unwrap(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("listLevel", 0.0), 5.0); + assert!(!changes(¶ms(app.ui.dialog.as_ref().unwrap())), "nothing changed yet"); + set(&mut app, "listLevel", json!(2)); + assert_eq!(params(app.ui.dialog.as_ref().unwrap()).get("listLevel"), Some(&json!(2.0))); + super::super::confirm(&mut app).unwrap(); + assert_eq!((first(&app, id).0.map(|l| l.level), paras(&app, id).1.list.map(|l| l.level)), (Some(2), Some(2))); + } + + /// `text.setFormat` sets `listLevel` only on paragraphs in a list, so a Level change alone on + /// paragraphs in no list sends nothing and adds no undo step; with a preset it applies. + #[test] + fn level_alone_on_paragraphs_in_no_list_adds_no_undo_step() { + let (mut app, id) = app(); + let undo = app.session.active().unwrap().history.undo.len(); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + set(&mut app, "listLevel", json!(3)); + assert!(!changes(¶ms(app.ui.dialog.as_ref().unwrap()))); + frame(&mut app); + assert!(!app.session.in_interaction(), "no preview"); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "no undo step"); + assert_eq!(first(&app, id), (None, 0.0, 0.0)); + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + set(&mut app, "listLevel", json!(3)); + pick_preset(&mut app, "bullet", Some("dot")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(first(&app, id).0, Some(ParaList::bulleted(Bullet::Dot).at_level(3))); + } + + /// The presets are radio buttons named for screen readers, the one the dialog applies chosen; + /// a click picks one, and ← and → move the choice from the focused one. + #[test] + fn the_presets_are_named_radio_buttons_that_the_arrow_keys_move_along() { + use egui::accesskit::{Role, Toggled}; + let names = ["None", "Filled Dot", "Dash"]; + let tree = crate::widgets::accessibility_tests::tree(|ui| { + let _ = preset_row(ui, "test", &names, Some(1), |_, _, _, _| {}); + }); + assert_eq!(crate::widgets::accessibility_tests::role_and_state(&tree, "Filled Dot"), (Role::RadioButton, Some(Toggled::True))); + assert_eq!(crate::widgets::accessibility_tests::role_and_state(&tree, "Dash"), (Role::RadioButton, Some(Toggled::False))); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + let origin = std::cell::Cell::new(egui::Pos2::ZERO); + let run = |events: Vec| { + let input = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }; + let mut picked = None; + let mut out = ctx.run_ui(input, |ui| { + origin.set(ui.cursor().min); + picked = preset_row(ui, "test", &names, Some(1), |_, _, _, _| {}); + }); + out.textures_delta.clear(); + picked + }; + let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + assert_eq!(run(vec![]), None); + assert_eq!(run(vec![key(egui::Key::ArrowRight)]), None, "no preset has the focus"); + ctx.memory_mut(|m| m.request_focus(egui::Id::unique(("bullets-numbering-preset", "test", 1usize)))); + run(vec![]); + assert_eq!(run(vec![key(egui::Key::ArrowRight)]), Some(2)); + assert_eq!(run(vec![key(egui::Key::ArrowLeft)]), Some(1), "the focus moved to Dash"); + assert_eq!(run(vec![key(egui::Key::ArrowLeft)]), Some(0)); + assert_eq!(run(vec![key(egui::Key::ArrowLeft)]), None, "None is the first"); + // The middle of the third button, two buttons and their 4 pt gaps along. + let at = origin.get() + egui::vec2(PRESET * 2.5 + 8.0, PRESET * 0.5); + let click = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + run(vec![egui::Event::PointerMoved(at)]); + assert_eq!(run(vec![click(true), click(false)]), Some(2)); + } +} diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs index 41530de0e..86f8fe453 100644 --- a/crates/ui-egui/src/dialogs/char_style_options.rs +++ b/crates/ui-egui/src/dialogs/char_style_options.rs @@ -101,7 +101,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec { "section", ] }, - min_width: 650.0, + min_width: 720.0, + max_width: Some(720.0), ..DialogSpec::FORM }; @@ -885,40 +886,81 @@ pub(crate) fn opentype_body(ui: &mut egui::Ui, d: &mut Dialog) { } } +/// The key under which the dialog keeps its footer's height for the next frame: the dialog's own +/// id, so the stored value is found on every frame. +fn footer_id(ui: &egui::Ui) -> egui::Id { + ui.layer_id().id.with("char-style-footer") +} + +/// The dialog's body: the section list beside the selected section's content, which scrolls when it +/// is taller than the window (General lists every attribute of the style and OpenType every +/// feature), and the footer below it - Reset Panel, the Preview checkbox and an invalid value's +/// error. The content's height is the space the footer leaves, measured last frame, so the buttons +/// stay on screen at any interface scale; the section list is outside the scroll area and stays put +/// while the content scrolls. Returns whether the dialog asks to be cancelled (never: Cancel comes +/// from the button row in `dialogs::show`). fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + // The content scrolls inside a shorter window: General lists every attribute of the style and + // OpenType every feature, so the window stays within the screen and its buttons stay visible. + // The footer was measured last frame, so the room left for the content keeps it on screen at any + // interface scale; the section list stays put while the content scrolls. + let footer = ui.ctx().data(|m| m.get_temp::(footer_id(ui)).unwrap_or(120.0)); + let room = (ui.available_height() - footer - 8.0).clamp(0.0, 420.0); ui.horizontal(|ui| { section_list(ui, d); ui.separator(); - ui.vertical(|ui| { - if d.str("section") == "basic" { - basic_body(ui, d); - } else if d.str("section") == "advanced" { - advanced_body(ui, d); - } else if d.str("section") == "color" { - color_body(ui, d); - } else if d.str("section") == "opentype" { - opentype_body(ui, d); - } else { - ui.horizontal(|ui| { - ui.label(tl!("Style Name:")); - form::text(ui, d, "name", 190.0); - }); - ui.separator(); - if let Ok(attrs) = edited_attrs(d) { - for (key, value) in attrs { - ui.label(format!("{}: {}", super::humanize(&key), value)); + // The section list handles a click before this frame's content is laid out, so the section + // read here is the one just picked: its own scroll offset is used from the first frame. + let section = d.str("section"); + let content_w = ui.available_width(); + // Long sections scroll both ways: at a large interface scale the window has fewer points, and + // a wide row (a character-format grid) must stay inside it rather than widen the window. + egui::ScrollArea::both().id_salt(("char-style-sections", section)).max_height(room).max_width(content_w).auto_shrink([false, true]).show( + ui, + |ui| { + ui.vertical(|ui| { + if d.str("section") == "basic" { + basic_body(ui, d); + } else if d.str("section") == "advanced" { + advanced_body(ui, d); + } else if d.str("section") == "color" { + color_body(ui, d); + } else if d.str("section") == "opentype" { + opentype_body(ui, d); + } else { + ui.horizontal(|ui| { + ui.label(tl!("Style Name:")); + form::text(ui, d, "name", 190.0); + }); + ui.separator(); + if let Ok(attrs) = edited_attrs(d) { + for (key, value) in attrs { + ui.label(format!("{}: {}", super::humanize(&key), value)); + } + } } - } - } - }); + }); + }, + ); }); - reset_button(ui, d); - match edited_attrs(d) { - Ok(attrs) => { - let params = json!({"name": d.str("__style"), "to": d.str("name"), "attrs": attrs}); - form::preview(app, ui, d, tl!("Character Style Options"), "charStyle.edit", params); - } - Err(why) => attrs_error(ui, &why), + let footer_h = ui + .scope(|ui| { + reset_button(ui, d); + match edited_attrs(d) { + Ok(attrs) => { + let params = json!({"name": d.str("__style"), "to": d.str("name"), "attrs": attrs}); + form::preview(app, ui, d, tl!("Character Style Options"), "charStyle.edit", params); + } + Err(why) => attrs_error(ui, &why), + } + }) + .response + .rect + .height(); + let was = ui.ctx().data(|m| m.get_temp::(footer_id(ui))); + ui.ctx().data_mut(|m| m.insert_temp(footer_id(ui), footer_h)); + if was != Some(footer_h) { + ui.ctx().request_repaint(); } false } @@ -952,6 +994,257 @@ mod tests { out.textures_delta.clear(); } + /// The texts a frame drew, with their screen rects. + fn texts_of(out: &egui::FullOutput) -> Vec<(String, egui::Rect)> { + fn collect(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + let mut texts = vec![]; + for c in &out.shapes { + collect(&c.shape, &mut texts); + } + texts + } + + /// The texts of a few headless frames in `ctx` at a screen `w` by `h`, with their screen rects: + /// the dialog lays its scroll area out and measures its footer over the first frames. + fn frame_texts_in(ctx: &egui::Context, app: &mut VectorcraftApp, w: f32, h: f32) -> Vec<(String, egui::Rect)> { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, h)); + let mut texts = vec![]; + for _ in 0..5 { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + texts = texts_of(&out); + } + texts + } + + /// The texts of a few headless frames at a screen `w` by `h`, and the context they were drawn in. + fn frame_texts_at(app: &mut VectorcraftApp, w: f32, h: f32) -> (egui::Context, Vec<(String, egui::Rect)>) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let texts = frame_texts_in(&ctx, app, w, h); + (ctx, texts) + } + + /// The General section lists every attribute of the style, so the sections scroll inside a + /// window shorter than that list: the buttons stay on the screen, the window keeps its width in + /// every section, and the content is clipped by the scroll area's viewport. + #[test] + fn the_sections_scroll_in_a_window_shorter_than_the_general_list() { + let mut app = app(); + app.run( + "charStyle.new", + json!({"name": "Body", "attrs": { + "font_family": "Source Sans 3", "font_style": "Regular", "size": 24.0, "leading": 30.0, + "kerning": 0.0, "tracking": 5.0, "all_caps": false, "small_caps": null, + "position": {"kind": "normal", "size": 100.0, "position": 0.0}, "underline": false, + "strikethrough": false, "standardVerticalRoman": false, "charAlign": "romanBaseline", + "h_scale": 100.0, "v_scale": 100.0, "baseline_shift": 0.0, "rotation": 0.0, + "language": "English: USA", "overprint_fill": false, "overprint_stroke": false, + "stroke_width": 0.0, "features": ["liga", "calt", "dlig", "swsh", "salt", "titl", "ordn", "frac"] + }}), + ) + .unwrap(); + open(&mut app, "Body").unwrap(); + let (ctx, texts) = frame_texts_at(&mut app, 900.0, 560.0); + let in_screen = |label: &str| { + let r = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn")).1; + r.top() >= 0.0 && r.bottom() <= 560.0 + 1.0 + }; + assert!(in_screen("OK") && in_screen("Cancel"), "the buttons stay on a 560-point screen"); + let dialog = egui::Id::unique(("dialog", "charStyleOptions")); + let width = ctx.memory(|m| m.area_rect(dialog)).expect("the dialog is drawn").width(); + assert!((700.0..=820.0).contains(&width), "the dialog keeps its 720-point width: {width}"); + let reset = texts.iter().find(|(text, _)| text == "Reset Panel").expect("the footer is drawn").1.top(); + assert!( + texts.iter().any(|(text, rect)| text.contains(':') && rect.bottom() > reset + 1.0), + "the General list is taller than the scroll area and runs under the footer: {texts:?}" + ); + // A short section uses the same width. + app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("basic")); + let _ = frame_texts_in(&ctx, &mut app, 900.0, 560.0); + let basic = ctx.memory(|m| m.area_rect(dialog)).expect("the dialog is drawn").width(); + assert!((basic - width).abs() < 0.5, "the width does not change with the section: {width} -> {basic}"); + } + /// The dialog stays usable in the smallest window the app allows (800 by 500 points): in every + /// section the section list, OK and Cancel are on the screen. + #[test] + fn every_section_and_the_buttons_fit_the_smallest_window() { + let mut app = app(); + app.run("charStyle.new", json!({"name": "Body"})).unwrap(); + open(&mut app, "Body").unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + const W: f32 = 800.0; + const H: f32 = 500.0; + let labels = ["General", "Basic Character Formats", "Advanced Character Formats", "Character Color", "OpenType Features", "OK", "Cancel"]; + for section in ["general", "basic", "advanced", "color", "opentype"] { + app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!(section)); + let texts = frame_texts_in(&ctx, &mut app, W, H); + for label in labels { + let r = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn in {section}")).1; + assert!(r.top() >= 0.0 && r.bottom() <= H + 1.0, "{label} stays on an 800 by 500 screen in {section}: {r:?}"); + } + } + } + + /// At 2x UI scale the smallest window (800 by 500 pixels) holds only 400 by 250 points: in + /// every section the section list and both buttons stay on the screen. + #[test] + fn every_section_and_the_buttons_fit_the_smallest_window_at_twice_the_scale() { + let mut app = app(); + app.run( + "charStyle.new", + json!({"name": "Body", "attrs": { + "font_family": "Source Sans 3", "font_style": "Regular", "size": 24.0, "leading": 30.0, + "kerning": 0.0, "tracking": 5.0, "all_caps": false, "small_caps": null, + "position": {"kind": "normal", "size": 100.0, "position": 0.0}, "underline": false, + "strikethrough": false, "standardVerticalRoman": false, "charAlign": "romanBaseline", + "h_scale": 100.0, "v_scale": 100.0, "baseline_shift": 0.0, "rotation": 0.0, + "language": "English: USA", "overprint_fill": false, "overprint_stroke": false, + "stroke_width": 0.0, "features": ["liga", "calt", "dlig", "swsh", "salt", "titl", "ordn", "frac"] + }}), + ) + .unwrap(); + open(&mut app, "Body").unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.set_zoom_factor(2.0); + const W: f32 = 400.0; + const H: f32 = 250.0; + let labels = ["General", "Basic Character Formats", "Advanced Character Formats", "Character Color", "OpenType Features", "OK", "Cancel"]; + for section in ["general", "basic", "advanced", "color", "opentype"] { + app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!(section)); + let texts = frame_texts_in(&ctx, &mut app, W, H); + for label in labels { + let r = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn in {section}")).1; + assert!( + r.top() >= 0.0 && r.bottom() <= H + 1.0 && r.left() >= 0.0 && r.right() <= W + 1.0, + "{label} stays on a 400 by 250 point screen at 2x in {section}: {r:?}" + ); + } + + // A wheel over the content scrolls it; the section list and both buttons stay put. + let at = egui::Pos2::new(300.0, 60.0); + let mut after = texts.clone(); + for _ in 0..8 { + let events = vec![ + egui::Event::PointerMoved(at), + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(-40.0, -40.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }, + ]; + let mut out = ctx.run_ui( + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(W, H))), events, ..Default::default() }, + |ui| super::super::show(&mut app, ui.ctx()), + ); + out.textures_delta.clear(); + after = texts_of(&out); + } + for label in labels { + let r = after.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn in {section}")).1; + assert!(r.top() >= 0.0 && r.bottom() <= H + 1.0, "{label} stays after scrolling in {section}: {r:?}"); + } + } + } + + /// Switching from a scrolled General section to a shorter one and back keeps General's own + /// scroll offset: the section is read after the list handles the click, so the click frame + /// stores no offset for the section that is leaving. + #[test] + fn a_scrolled_section_keeps_its_offset_after_switching_away_and_back() { + let mut app = app(); + app.run( + "charStyle.new", + json!({"name": "Body", "attrs": { + "font_family": "Source Sans 3", "font_style": "Regular", "size": 24.0, "leading": 30.0, + "kerning": 0.0, "tracking": 5.0, "all_caps": false, "small_caps": null, + "position": {"kind": "normal", "size": 100.0, "position": 0.0}, "underline": false, + "strikethrough": false, "standardVerticalRoman": false, "charAlign": "romanBaseline", + "h_scale": 100.0, "v_scale": 100.0, "baseline_shift": 0.0, "rotation": 0.0, + "language": "English: USA", "overprint_fill": false, "overprint_stroke": false, + "stroke_width": 0.0, "features": ["liga", "calt", "dlig", "swsh", "salt", "titl", "ordn", "frac"] + }}), + ) + .unwrap(); + open(&mut app, "Body").unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 560.0)); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = + ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + texts_of(&out) + }; + let mut texts = vec![]; + for _ in 0..5 { + texts = frame(&mut app, vec![]); + } + let y = |texts: &[(String, egui::Rect)], label: &str| { + texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn")).1.top() + }; + eprintln!("DEBUG initial texts: {texts:?}"); + let general_top = y(&texts, "Style Name:"); + let before = texts.clone(); + // Scroll General down with the wheel. + let at = egui::Pos2::new(600.0, 200.0); + for _ in 0..8 { + let events = vec![ + egui::Event::PointerMoved(at), + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -40.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }, + ]; + texts = frame(&mut app, events); + } + // Let the smooth wheel scroll finish before changing sections. + for _ in 0..15 { + texts = frame(&mut app, vec![]); + } + let scrolled = texts.clone(); + assert!( + before.iter().any(|(label, _)| !scrolled.iter().any(|(other, _)| other == label)) + && scrolled.iter().any(|(label, _)| !before.iter().any(|(other, _)| other == label)), + "General scrolls to other attributes" + ); + // Click Basic Character Formats, then General. + let click = |app: &mut VectorcraftApp, label: &str| { + let texts = frame(app, vec![]); + let pos = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn")).1.center(); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for e in [egui::Event::PointerMoved(pos), button(true), button(false)] { + let _ = frame(app, vec![e]); + } + let _ = frame(app, vec![]); + }; + click(&mut app, "Basic Character Formats"); + let basic = frame(&mut app, vec![]); + assert!(y(&basic, "Font Family:") < general_top + 40.0, "a short section opens at its top"); + click(&mut app, "General"); + let back = frame(&mut app, vec![]); + let mut same = 0; + for (label, old) in &scrolled { + if let Some((_, new)) = back.iter().find(|(text, _)| text == label) { + assert!((new.top() - old.top()).abs() < 1.0, "General keeps its offset at {label}: {old:?} -> {new:?}"); + same += 1; + } + } + assert!(same > 0, "General draws its content again: {back:?}"); + } #[test] fn unchanged_basic_fields_preserve_original_keys() { for attrs in [ diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs index fdbf335cb..c4ef1a984 100644 --- a/crates/ui-egui/src/dialogs/color_picker.rs +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -115,6 +115,23 @@ impl Channel { Channel::Blue => (2, 0, 1), } } + /// The color field's axes (across, up) as keys and screen readers see them: hue in degrees, + /// saturation and brightness in percent, red, green and blue from 0 to 255. + fn field_axes(self) -> [widgets::FieldAxis<'static>; 2] { + let axis = |i: usize| { + let (name, units, suffix) = match (self.is_hsb(), i) { + (true, 0) => (tl!("Hue"), 360.0, "°"), + (true, 1) => (tl!("Saturation"), 100.0, "%"), + (true, _) => (tl!("Brightness"), 100.0, "%"), + (false, 0) => (tl!("Red"), 255.0, ""), + (false, 1) => (tl!("Green"), 255.0, ""), + (false, _) => (tl!("Blue"), 255.0, ""), + }; + widgets::FieldAxis { name, units, suffix } + }; + let (_, x, y) = self.axes(); + [axis(x), axis(y)] + } } /// The picked colour with its HSB (hue in degrees), kept so the hue survives grey colours. @@ -367,7 +384,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> DialogAction { } else { let (z, x, y) = field_pos(channel, &p); let at = |x: f32, y: f32| c32(&field_pick(channel, z, x, y).snapped(web).color); - if let (Some((x, y)), _) = widgets::color_field(ui, "cp-field", vec2(side, side), (x, y), &at) { + if let (Some((x, y)), _) = + widgets::color_field(ui, "cp-field", (tl!("Color field"), channel.field_axes()), vec2(side, side), (x, y), true, &at) + { pick = Some(field_pick(channel, z, x, y)); } ui.add_space(SLIDER_GAP); diff --git a/crates/ui-egui/src/dialogs/composite_fonts.rs b/crates/ui-egui/src/dialogs/composite_fonts.rs new file mode 100644 index 000000000..cc71cc32d --- /dev/null +++ b/crates/ui-egui/src/dialogs/composite_fonts.rs @@ -0,0 +1,275 @@ +//! Type › Composite Fonts… (with Show East Asian Options): the active document's composite fonts, +//! the one picked in Font shown as a table with a row for each class of character (Kanji, Kana, +//! Punctuation, Symbols, Roman, Numerals) and its font, style, size, baseline and scales. New adds +//! a font based on the one picked, Delete Font takes it out, Import… adds the fonts of a file and +//! Export… writes the one picked to a file. Nothing changes in the document until OK, which runs +//! `type.compositeFonts.set` with every font as one undo step; Cancel leaves them as they were. +//! +//! Fields: `fonts` (the composite fonts as `type.compositeFonts.set` takes them), `selected` (the +//! index of the one picked) and `name` (its name as typed). +//! +//! Behavior reference (public documentation): +//! . + +use serde_json::{Value, json}; +use vectorcraft_doc::text::{COMPOSITE_BASELINE, COMPOSITE_SCALE, COMPOSITE_SIZE}; +use vectorcraft_doc::{CharClass, CompositeFont, CompositePart}; +use vectorcraft_engine::cmd::compositefonts::EXTS; + +use super::{DialogSpec, form}; +use crate::picks::PickRequest; +use crate::state::Dialog; +use crate::{FilePick, VectorcraftApp, widgets}; + +/// The dialog kind of Composite Fonts. +pub const KIND: &str = "compositeFonts"; + +/// The command OK runs, and the menu item's. +pub const COMMAND: &str = "type.compositeFonts.set"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Composite Fonts").into(), body, confirm, min_width: 760.0, ..DialogSpec::FORM }; + +/// Open Composite Fonts on the active document's composite fonts, the first one picked. +pub fn open(app: &mut VectorcraftApp) -> Result { + let fonts = app.session.active().ok_or("no document open")?.doc.composite_fonts.to_vec(); + let mut d = Dialog::new(KIND, json!({ "fonts": fonts, "selected": 0 })); + show_selected(&mut d); + app.ui.dialog = Some(d); + Ok(Value::Null) +} + +/// The fonts the dialog holds. +fn fonts(d: &Dialog) -> Vec { + d.fields.get("fonts").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default() +} + +fn put_fonts(d: &mut Dialog, fonts: &[CompositeFont]) { + d.fields.insert("fonts".into(), json!(fonts)); +} + +/// The index of the font picked, within the fonts (0 when there are none). +fn selected(d: &Dialog, count: usize) -> usize { + d.fields.get("selected").and_then(Value::as_u64).map_or(0, |i| i as usize).min(count.saturating_sub(1)) +} + +/// Pick font `i`: its name goes into Name. +fn select(d: &mut Dialog, i: usize) { + d.fields.insert("selected".into(), json!(i)); + show_selected(d); +} + +fn show_selected(d: &mut Dialog) { + let fonts = fonts(d); + let name = fonts.get(selected(d, fonts.len())).map(|f| f.name.clone()).unwrap_or_default(); + d.fields.insert("name".into(), json!(name)); +} + +/// The name of a class's row. +fn class_label(c: CharClass) -> &'static str { + match c { + CharClass::Kanji => tl!("Kanji"), + CharClass::Kana => tl!("Kana"), + CharClass::Punctuation => tl!("Punctuation"), + CharClass::Symbols => tl!("Symbols"), + CharClass::Roman => tl!("Roman"), + CharClass::Numerals => tl!("Numerals"), + } +} + +/// "Composite Font N", the first N no font in `fonts` and no installed font is named. +fn new_name(fonts: &[CompositeFont]) -> String { + let db = vectorcraft_text::FontDb::global(); + (1..) + .map(|n| format!("Composite Font {n}")) + .find(|n| !fonts.iter().any(|f| CompositeFont::same_name(&f.name, n)) && (db.composite_font(n).is_some() || !db.has_family(n))) + .unwrap_or_default() +} + +/// What a button asked for, done once the table is drawn. +enum Action { + New, + Delete, + Import, + Export, +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let mut list = fonts(d); + let i = selected(d, list.len()); + let mut act = None; + let names: Vec<&str> = list.iter().map(|f| f.name.as_str()).collect(); + let pick = FilePick { filters: vec![FilePick::filter("Composite fonts", EXTS)], ..Default::default() }; + let can_import = crate::picks::can(app, &PickRequest::Open(pick)); + let mut picked = None; + ui.horizontal(|ui| { + widgets::dim_label(ui, tl!("Font:")); + if names.is_empty() { + widgets::dim_label(ui, tl!("None")); + } else if let Some(k) = widgets::dropdown_names(ui, "composite-fonts-font", names.get(i).copied().unwrap_or_default(), &names, 220.0) { + picked = Some(k); + } + ui.add_space(8.0); + ui.spacing_mut().item_spacing.x = 4.0; + if widgets::flat_button(ui, tl!("New"), 48.0).clicked() { + act = Some(Action::New); + } + if ui.add_enabled_ui(!list.is_empty(), |ui| widgets::flat_button(ui, tl!("Delete Font"), 84.0)).inner.clicked() { + act = Some(Action::Delete); + } + if can_import && widgets::flat_button(ui, tl!("Import…"), 64.0).clicked() { + act = Some(Action::Import); + } + if ui.add_enabled_ui(!list.is_empty(), |ui| widgets::flat_button(ui, tl!("Export…"), 64.0)).inner.clicked() { + act = Some(Action::Export); + } + }); + if let Some(k) = picked { + select(d, k); + return false; + } + ui.add_space(8.0); + if let Some(f) = list.get_mut(i) { + ui.horizontal(|ui| { + widgets::dim_label(ui, tl!("Name:")); + form::text(ui, d, "name", 260.0); + }); + f.name = d.str("name"); + ui.add_space(8.0); + parts_table(app, ui, f); + } + put_fonts(d, &list); + if let Some(a) = act { + // Import…'s file dialog, shown off the UI thread, imports into the dialog as it is then. + let r = match a { + Action::Import => crate::picks::in_dialog(app, d, |app, d| run(app, d, Action::Import)), + a => run(app, d, a), + }; + if let Err(e) = r { + app.status(e); + } + } + false +} + +/// Do what button `act` asks for on the dialog's fonts. +fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action) -> Result<(), String> { + let mut list = fonts(d); + let i = selected(d, list.len()); + match act { + Action::New => { + let mut f = list.get(i).cloned().unwrap_or_else(|| { + let st = vectorcraft_doc::CharStyle::default(); + CompositeFont::new("", st.font_family, st.font_style) + }); + f.name = new_name(&list); + list.push(f); + put_fonts(d, &list); + select(d, list.len() - 1); + } + Action::Delete => { + if i < list.len() { + list.remove(i); + } + put_fonts(d, &list); + select(d, i.min(list.len().saturating_sub(1))); + } + Action::Import => import(app, d)?, + Action::Export => { + let f = list.get(i).ok_or("no composite font")?; + crate::io::save_command_output(app, "type.compositeFonts.export", EXTS[0], json!({ "fonts": [f] }))?; + } + } + Ok(()) +} + +/// The table of `f`'s parts: a row for each class with its font, style, size, baseline, scales and +/// (Kana) Scale at Center; the values a class can't take are not shown. +fn parts_table(app: &VectorcraftApp, ui: &mut egui::Ui, f: &mut CompositeFont) { + let db = vectorcraft_text::FontDb::global(); + egui::Grid::new("composite-fonts-parts").num_columns(8).spacing([8.0, 6.0]).show(ui, |ui| { + for h in ["", tl!("Font"), tl!("Style"), tl!("Size"), tl!("Baseline"), tl!("Horizontal Scale"), tl!("Vertical Scale"), ""] { + widgets::dim_label(ui, h); + } + ui.end_row(); + for class in CharClass::ALL { + let p: &mut CompositePart = f.parts.get_mut(class); + ui.label(class_label(class)); + let id = ("composite-font", class.key()); + if let Some(crate::font_menu::FontPick::Chosen(family, style)) = + crate::font_menu::font_menu(ui, id, &p.font, 190.0, None, crate::font_menu::MenuLook::of(app)) + && db.composite_font(&family).is_none() + { + p.style = style.unwrap_or_else(|| db.styles(&family).first().cloned().unwrap_or_else(|| "Regular".into())); + p.font = family; + } + let styles = db.styles(&p.font); + let style_names: Vec<&str> = styles.iter().map(String::as_str).collect(); + if let Some(k) = widgets::dropdown_names(ui, ("composite-font-style", class.key()), &p.style, &style_names, 120.0) + && let Some(s) = styles.get(k) + { + p.style.clone_from(s); + } + let percent = |ui: &mut egui::Ui, key: &str, v: &mut f64, range: &std::ops::RangeInclusive| { + if let Some(n) = widgets::range_field(ui, ("composite-font", class.key(), key), *v, range.clone(), "%", 1, 64.0) { + *v = n; + } + }; + percent(ui, "size", &mut p.size, &COMPOSITE_SIZE); + percent(ui, "baseline", &mut p.baseline, &COMPOSITE_BASELINE); + if class.scales() { + percent(ui, "hScale", &mut p.h_scale, &COMPOSITE_SCALE); + percent(ui, "vScale", &mut p.v_scale, &COMPOSITE_SCALE); + } else { + ui.label(""); + ui.label(""); + } + if class == CharClass::Kana { + ui.checkbox(&mut p.scale_at_center, tl!("Scale at Center")); + } else { + ui.label(""); + } + ui.end_row(); + } + }); +} + +/// Import…: the fonts of a picked file join those in the dialog, a font of the same name replaced, +/// and the first of them is picked. +fn import(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> { + let pick = FilePick { filters: vec![FilePick::filter("Composite fonts", EXTS)], ..Default::default() }; + let path = crate::picks::open(app, &pick).ok_or("cancelled")?; + let r = app.run("type.compositeFonts.import", json!({ "path": path, "apply": false }))?; + let read: Vec = serde_json::from_value(r["fonts"].clone()).map_err(|e| e.to_string())?; + let mut list = fonts(d); + let mut first = None; + for f in read { + let at = match list.iter_mut().enumerate().find(|(_, g)| CompositeFont::same_name(&g.name, &f.name)) { + Some((k, g)) => { + *g = f; + k + } + None => { + list.push(f); + list.len() - 1 + } + }; + first.get_or_insert(at); + } + put_fonts(d, &list); + if let Some(k) = first { + select(d, k); + } + Ok(()) +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let r = app.run(COMMAND, json!({ "fonts": fonts(d) })); + // A name two fonts share, or a value out of range, keeps the dialog open to be changed. + if r.is_ok() { + app.ui.dialog = None; + } + r +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/dialogs/composite_fonts/tests.rs b/crates/ui-egui/src/dialogs/composite_fonts/tests.rs new file mode 100644 index 000000000..64a031e04 --- /dev/null +++ b/crates/ui-egui/src/dialogs/composite_fonts/tests.rs @@ -0,0 +1,174 @@ +use serde_json::{Value, json}; +use vectorcraft_engine::Session; + +use super::*; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + app +} + +fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); +} + +fn dialog(app: &mut VectorcraftApp) -> &mut Dialog { + app.ui.dialog.as_mut().expect("Composite Fonts open") +} + +fn names(app: &VectorcraftApp) -> Vec { + app.session.active().unwrap().doc.composite_fonts.iter().map(|f| f.name.clone()).collect() +} + +fn steps(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().history.undo.len() +} + +/// Do `act` with the dialog as it is, as its button does. +fn press(app: &mut VectorcraftApp, act: Action) { + let mut d = app.ui.dialog.clone().unwrap(); + run(app, &mut d, act).unwrap(); + app.ui.dialog = Some(d); +} + +/// Type › Composite Fonts… opens on the document's fonts; New adds one, its parts edited in the +/// table; nothing changes until OK, which sets them as one undo step; Cancel leaves them. +#[test] +fn composite_fonts_change_on_ok_as_one_undo_step() { + let mut app = app(); + crate::menus::invoke(&mut app, COMMAND, json!({})); + assert_eq!(dialog(&mut app).kind, KIND); + frame(&mut app); + press(&mut app, Action::New); + frame(&mut app); + let d = dialog(&mut app); + assert_eq!(d.str("name"), "Composite Font 1"); + let mut list = fonts(d); + list[0].parts.roman.font = "JetBrains Mono".into(); + list[0].parts.roman.size = 80.0; + list[0].parts.kana.scale_at_center = true; + put_fonts(d, &list); + d.fields.insert("name".into(), json!("UI Composite A")); + frame(&mut app); + assert!(names(&app).is_empty()); + let before = steps(&app); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(names(&app), ["UI Composite A"]); + assert_eq!(steps(&app), before + 1); + let f = &app.session.active().unwrap().doc.composite_fonts[0]; + assert_eq!((f.parts.roman.font.as_str(), f.parts.roman.size, f.parts.kana.scale_at_center), ("JetBrains Mono", 80.0, true)); + // OK with nothing changed adds no step; Cancel leaves the fonts. + open(&mut app).unwrap(); + super::super::confirm(&mut app).unwrap(); + assert_eq!(steps(&app), before + 1); + open(&mut app).unwrap(); + press(&mut app, Action::Delete); + super::super::cancel(&mut app); + assert_eq!(names(&app), ["UI Composite A"]); + // Delete Font and OK takes it out. + open(&mut app).unwrap(); + press(&mut app, Action::Delete); + assert_eq!(dialog(&mut app).str("name"), ""); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert!(names(&app).is_empty()); +} + +/// New is based on the font picked and takes the next free name; a name two fonts share, or one +/// left empty, keeps the dialog open and changes nothing. +#[test] +fn shared_or_empty_names_keep_the_dialog_open() { + let mut app = app(); + open(&mut app).unwrap(); + press(&mut app, Action::New); + let d = dialog(&mut app); + let mut list = fonts(d); + list[0].parts.numerals.font = "Inter".into(); + put_fonts(d, &list); + press(&mut app, Action::New); + let list = fonts(dialog(&mut app)); + assert_eq!(list.iter().map(|f| f.name.as_str()).collect::>(), ["Composite Font 1", "Composite Font 2"]); + assert_eq!(list[1].parts.numerals.font, "Inter"); + for bad in ["composite font 1", " "] { + dialog(&mut app).fields.insert("name".into(), json!(bad)); + frame(&mut app); + assert!(super::super::confirm(&mut app).is_err(), "{bad:?}"); + assert!(app.ui.dialog.is_some()); + assert!(names(&app).is_empty()); + } + dialog(&mut app).fields.insert("name".into(), json!("UI Composite B")); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert_eq!(names(&app), ["Composite Font 1", "UI Composite B"]); +} + +/// The menu item shows only with Show East Asian Options on, and needs a document. +#[test] +fn the_menu_item_shows_with_east_asian_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.show_east_asian_options = true; + assert_eq!(crate::menus::shown_state(&app, COMMAND, &json!({})).map(|s| s.0), Some(false)); + app.run("file.new", json!({})).unwrap(); + assert_eq!(crate::menus::shown_state(&app, COMMAND, &json!({})).map(|s| s.0), Some(true)); + app.session.prefs.show_east_asian_options = false; + assert_eq!(crate::menus::shown_state(&app, COMMAND, &json!({})), None); +} + +/// Export… writes the font picked to a file; Import… adds the fonts of a file to the dialog's, +/// replacing one of the same name, and opening the file adds them to the document. +#[test] +fn composite_fonts_go_through_files() { + let dir = std::env::temp_dir().join(format!("vc-composite-fonts-ui-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("fonts.vccomposite").to_string_lossy().to_string(); + let (saved, picked) = (file.clone(), file.clone()); + let services = crate::Services { + pick_save: Some(Box::new(move |_: &FilePick| Some(saved.clone()))), + pick_open: Some(Box::new(move |_: &FilePick| Some(picked.clone()))), + write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({})).unwrap(); + open(&mut app).unwrap(); + press(&mut app, Action::New); + let d = dialog(&mut app); + let mut list = fonts(d); + list[0].name = "UI Composite IO".into(); + list[0].parts.roman.baseline = -12.0; + put_fonts(d, &list); + press(&mut app, Action::Export); + assert!(std::fs::read_to_string(&file).unwrap().contains("UI Composite IO")); + // Import into the dialog: the same name is replaced, not added. + let d = dialog(&mut app); + let mut list = fonts(d); + list[0].parts.roman.baseline = 0.0; + put_fonts(d, &list); + press(&mut app, Action::Import); + let list = fonts(dialog(&mut app)); + assert_eq!((list.len(), list[0].parts.roman.baseline), (1, -12.0)); + super::super::cancel(&mut app); + assert!(names(&app).is_empty()); + // Opening the file adds its fonts to the document. + let bytes = std::fs::read(&file).unwrap(); + crate::io::open_bytes(&mut app, &file, &bytes, Some(file.clone())).unwrap(); + assert_eq!(names(&app), ["UI Composite IO"]); + let _ = std::fs::remove_dir_all(dir); +} + +/// The dialog lists the fonts and draws its table without a document font picked too. +#[test] +fn the_dialog_draws_with_and_without_fonts() { + let mut app = app(); + open(&mut app).unwrap(); + frame(&mut app); + assert_eq!(dialog(&mut app).fields.get("fonts"), Some(&Value::Array(vec![]))); + press(&mut app, Action::New); + frame(&mut app); + assert_eq!(fonts(dialog(&mut app)).len(), 1); +} diff --git a/crates/ui-egui/src/dialogs/dxf_options.rs b/crates/ui-egui/src/dialogs/dxf_options.rs index dc6a54d16..cc5262393 100644 --- a/crates/ui-egui/src/dialogs/dxf_options.rs +++ b/crates/ui-egui/src/dialogs/dxf_options.rs @@ -1,13 +1,15 @@ -//! DXF Options (Export As → DXF): version, scale (1 unit of the art = N drawing units) and -//! lineweights, number of colours, the format of placed images, Preserve Appearance or Maximize -//! Editability, and Export Selected Art Only, Alter Paths for Appearance and Outline Text. +//! DXF Options (Export As → DXF): a preset (Custom, or CNC / CAM, which sets the options below for +//! cutting software and shows Custom again once one is changed), version, scale (1 unit of the +//! art = N drawing units) and lineweights, number of colours, the format of placed images, +//! Preserve Appearance or Maximize Editability, and Export Selected Art Only, Alter Paths for +//! Appearance and Outline Text. //! //! Fields: `document.export` params (`format: dxf`, `path?`, the artboard choice) and the DXF //! options of `document.exportDxf`. OK remembers the options for next time and writes the file //! (asking where when there is no path); bad options keep the dialog open. use serde_json::{Map, Value, json}; -use vectorcraft_cad::{ColorDepth, DxfVersion, Preserve, RasterFormat}; +use vectorcraft_cad::{ColorDepth, DxfPreset, DxfVersion, Preserve, RasterFormat}; use vectorcraft_doc::Unit; use vectorcraft_engine::cmd::fileio; @@ -32,40 +34,95 @@ pub(super) const SPEC: DialogSpec = DialogSpec { /// Fields that are not remembered between exports (they belong to one export). const PER_EXPORT: [&str; 7] = ["format", "path", "useArtboards", "range", "artboard", "artboards", "selectedOnly"]; +/// The preset field (the `dxfPreset` param). +const PRESET: &str = "dxfPreset"; +/// The preset the fields were last set from or matched (a change of [`PRESET`] sets its fields). +const SHOWN_PRESET: &str = "__shownPreset"; + /// The options a first DXF Options dialog starts from (the export's defaults). fn defaults() -> Map { + let mut m = preset_fields(DxfPreset::Custom); + m.insert(PRESET.into(), json!(DxfPreset::Custom.id())); + m.insert("selectedOnly".into(), json!(false)); + m +} + +/// The fields `preset` sets (Custom: the export's defaults). +fn preset_fields(preset: DxfPreset) -> Map { + let o = preset.options(); let mut m = Map::new(); for (k, v) in [ - ("version", json!(DxfVersion::default().id())), - ("unit", json!(Unit::Millimeters.label())), - ("scale", json!(1)), - ("scaleLineweights", json!(false)), - ("colors", json!(ColorDepth::default().id())), - ("rasterFormat", json!(RasterFormat::default().id())), - ("preserve", json!(Preserve::default().id())), - ("alterPaths", json!(false)), - ("outlineText", json!(false)), - ("selectedOnly", json!(false)), + ("version", json!(o.version.id())), + ("unit", json!(o.unit.label())), + ("scale", json!(o.scale)), + ("scaleLineweights", json!(o.scale_lineweights)), + ("colors", json!(o.colors.id())), + ("rasterFormat", json!(o.raster.id())), + ("preserve", json!(o.preserve.id())), + ("alterPaths", json!(o.alter_paths)), + ("outlineText", json!(o.outline_text)), ] { m.insert(k.into(), v); } m } +/// Show the preset the fields match: CNC / CAM while every field it sets is as it sets it, else +/// Custom. An unknown preset stays as it is, for OK to refuse. +fn show_matching_preset(d: &mut Dialog) { + if DxfPreset::from_id(&d.str(PRESET)).is_none() { + return; + } + let preset = fileio::dxf::options(&super::form::params(d)).map_or(DxfPreset::Custom, |o| DxfPreset::of(&o)); + d.fields.insert(PRESET.into(), json!(preset.id())); + d.fields.insert(SHOWN_PRESET.into(), json!(preset.id())); +} + +/// A preset picked since the fields were last matched, or any preset when `force` is set, sets +/// its fields; Custom puts back the export's defaults. +fn apply_picked_preset(d: &mut Dialog, force: bool) { + if (force || !d.str(PRESET).eq_ignore_ascii_case(&d.str(SHOWN_PRESET))) + && let Some(p) = DxfPreset::from_id(&d.str(PRESET)) + { + d.fields.extend(preset_fields(p)); + } +} + +/// `field` was set (`ui.dialog.set`): a preset sets its fields now, even the one shown, so fields +/// set after it keep their values, and any other field shows the preset the fields then match, as +/// drawing does. +pub(super) fn note_set(d: &mut Dialog, field: &str) { + if field == PRESET { + apply_picked_preset(d, true); + } + show_matching_preset(d); +} + /// Open DXF Options for an export whose params (path, artboard choice, any DXF option) are -/// `params`, over the options used last. +/// `params`, over the options used last. A `dxfPreset` in `params` sets its fields under the +/// options `params` gives. pub fn open(app: &mut VectorcraftApp, params: &Value) { let mut fields = defaults(); - for given in [&app.ui.dxf_options, params] { - if let Some(o) = given.as_object() { - fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))); + if let Some(o) = app.ui.dxf_options.as_object() { + fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + if let Some(o) = params.as_object() { + if let Some(p) = o.get(PRESET).and_then(Value::as_str).and_then(DxfPreset::from_id) { + fields.extend(preset_fields(p)); } + fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))); } fields.insert("format".into(), json!("dxf")); - app.ui.dialog = Some(Dialog::new(KIND, Value::Object(fields))); + let mut d = Dialog::new(KIND, Value::Object(fields)); + show_matching_preset(&mut d); + app.ui.dialog = Some(d); } fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let presets: Vec<(&str, &str)> = DxfPreset::ALL.iter().map(|p| (p.id(), p.label())).collect(); + choice(ui, d, PRESET, tl!("Preset:"), &presets); + apply_picked_preset(d, false); + ui.add_space(4.0); let versions: Vec<(&str, &str)> = DxfVersion::ALL.iter().map(|v| (v.id(), v.label())).collect(); choice(ui, d, "version", tl!("Version:"), &versions); ui.add_space(4.0); @@ -113,6 +170,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { Preserve::Editability => tl!("Type stays text, and every stroke is a line with its lineweight and dashes."), }, ); + show_matching_preset(d); false } @@ -146,14 +204,20 @@ fn note(ui: &mut egui::Ui, text: &str) { /// Check the options, remember them, and write the file(s). fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + // A preset or field set since the dialog last drew (the control channel sets fields without + // drawing) counts as drawn. + let mut d = d.clone(); + apply_picked_preset(&mut d, false); + show_matching_preset(&mut d); + let d = &d; let params = super::form::params(d); fileio::dxf::check(¶ms)?; // Nothing selected keeps the dialog open too. if let Some(st) = app.session.active() { fileio::export_source(st, ¶ms).map_err(|e| e.to_string())?; } - app.ui.dxf_options = - Value::Object(d.fields.iter().filter(|(k, _)| !PER_EXPORT.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect()); + let remembered = params.as_object().map(|o| o.iter().filter(|(k, _)| !PER_EXPORT.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone()))); + app.ui.dxf_options = Value::Object(remembered.into_iter().flatten().collect()); app.ui.dialog = None; let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string); io::export(app, Some("dxf"), path, ¶ms) diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index a77d0eed6..93688542c 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -64,6 +64,10 @@ pub(super) const EXISTS_SPEC: DialogSpec = DialogSpec { /// or add `effect`, first asking when that list already has it. pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { let id = p.get("effect").and_then(Value::as_str).unwrap_or_default(); + // An SVG filter is chosen and edited in Apply SVG Filter. + if id == vectorcraft_effects::SVG_FILTER { + return super::svg_filter::open(app, p); + } let info = vectorcraft_effects::effect_info(id).ok_or_else(|| format!("unknown effect `{id}`"))?; // The list the effect goes to: `item` as given, else the active item's (the object's without // one). A new effect keeps `item` unresolved, so `effect.apply` targets each object as usual. @@ -346,15 +350,20 @@ fn choices(effect: &str, key: &str) -> Option { /// The parameters whose dialogs list their values in their own bodies, as (label, value), each /// label as that dialog passes it to its dropdown or radio buttons, which translate it into the UI -/// language: Revolve's edge, Extrude & Bevel's bevel (Rounded translated in the "3d" context -/// first), every Warp style's orientation, Rasterize's Color Model (its `document` value labeled +/// language: Revolve's edge, the bevel of both Extrude & Bevels (Rounded translated in the "3d" +/// context first), Extrude & Bevel (Classic)'s surface and shading color, every Warp style's +/// orientation, Rasterize's Color Model (its `document` value labeled /// CMYK when `cmyk`, for a document in CMYK, and RGB otherwise), Background and Anti-aliasing, and /// the channel of Levels and Curves. pub(super) fn own_choices(effect: &str, key: &str, cmyk: bool) -> Option> { let c3 = |s: &'static str| crate::i18n::tr_ctx(crate::i18n::current(), "3d", s); Some(match (effect, key) { (vectorcraft_effects::REVOLVE, "edge") => vec![(tl!("Left"), "left"), (tl!("Right"), "right")], - (vectorcraft_effects::EXTRUDE, "bevel") => vec![ + (vectorcraft_effects::CLASSIC_EXTRUDE, "surface") => { + vec![(tl!("Wireframe"), "wireframe"), (tl!("No Shading"), "none"), (tl!("Diffuse"), "diffuse"), (tl!("Plastic"), "plastic")] + } + (vectorcraft_effects::CLASSIC_EXTRUDE, "shadingColor") => vec![(tl!("Black"), "black"), (tl!("Custom"), "custom"), (tl!("None"), "none")], + (vectorcraft_effects::EXTRUDE | vectorcraft_effects::CLASSIC_EXTRUDE, "bevel") => vec![ (tl!("None"), "none"), (tl!("Classic"), "classic"), (c3("Rounded"), "rounded"), @@ -915,6 +924,9 @@ mod tests { for (effect, key) in [ (vectorcraft_effects::REVOLVE, "edge"), (vectorcraft_effects::EXTRUDE, "bevel"), + (vectorcraft_effects::CLASSIC_EXTRUDE, "bevel"), + (vectorcraft_effects::CLASSIC_EXTRUDE, "surface"), + (vectorcraft_effects::CLASSIC_EXTRUDE, "shadingColor"), ("warp.arc", "orientation"), ("warp.twist", "orientation"), (vectorcraft_effects::RASTERIZE, "background"), diff --git a/crates/ui-egui/src/dialogs/export_as.rs b/crates/ui-egui/src/dialogs/export_as.rs index 32ee696af..246aaf23f 100644 --- a/crates/ui-egui/src/dialogs/export_as.rs +++ b/crates/ui-egui/src/dialogs/export_as.rs @@ -28,7 +28,7 @@ const UNAVAILABLE: [(&str, &str); 1] = [("dwg", "DWG (use DXF)")]; /// The formats Export As writes: the built-in ones, then those of the installed exporter plug-ins. pub(super) fn formats() -> Vec<&'static Format> { - BUILT_IN.iter().copied().chain(fileio::plugin_formats()).collect() + BUILT_IN.iter().copied().chain(fileio::plugin_formats().into_iter().filter(|f| f.write)).collect() } /// Is `f` an exporter plug-in's format? diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index 24f2d7367..88f9d35c8 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -27,6 +27,7 @@ use serde::Deserialize; use serde_json::{Map, Value, json}; use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, ScreenSize, pdf}; +use vectorcraft_engine::cmd::params::{drop_unread_row_options, screen_formats}; use super::{DialogSpec, form, png_options, svg_options}; use crate::state::Dialog; @@ -108,12 +109,13 @@ pub(crate) fn saved_rows(saved: &Map) -> (Vec, Option<&str /// Open the dialog on its Assets tab with `checked` assets checked (every asset when `None`). pub(crate) fn open_assets(app: &mut VectorcraftApp, checked: Option<&[u64]>) { open(app); + let shown: Vec = app.session.active().map(|st| picture_assets(st).iter().map(|a| a.id).collect()).unwrap_or_default(); if let Some(d) = app.ui.dialog.as_mut() { d.fields.insert("tab".into(), json!("assets")); if let Some(c) = checked { d.fields.insert("assets".into(), json!(c)); - // The list scrolls to the first of them once: new assets land at its end. - if let Some(first) = c.first() { + // The list scrolls to the first of them it shows, once: new assets land at its end. + if let Some(first) = c.iter().find(|id| shown.contains(id)) { d.fields.insert("__scrollTo".into(), json!(first)); } } @@ -128,7 +130,7 @@ pub fn open(app: &mut VectorcraftApp) { let (n, saved, assets) = app .session .active() - .map(|st| (st.doc.artboards.len(), st.doc.export_settings.clone(), st.doc.assets.iter().map(|a| a.id).collect::>())) + .map(|st| (st.doc.artboards.len(), st.doc.export_settings.clone(), picture_assets(st).iter().map(|a| a.id).collect::>())) .unwrap_or_default(); let get = |k: &str| saved.get(k).filter(|v| !v.is_null()); let flag = |k: &str, default: bool| get(k).and_then(Value::as_bool).unwrap_or(default); @@ -212,12 +214,29 @@ pub(crate) fn saved_options(app: &VectorcraftApp, saved: &Map, id options.insert("antiAlias".into(), aa.clone()); } if let Some(o) = saved.get("settings").and_then(|v| v.get(id)).and_then(Value::as_object) { - options.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))); + let mut o = o.clone(); + drop_unread_row_options(&mut o); + options.extend(o); } options } -/// A row with its size as the text the Scale field shows (`2x`, `100w`, `100h`, `72ppi`). +/// Export settings `settings` (as `document.exportSettings` or a preset holds them) without the +/// keys the export no longer reads in its rows and per-format settings, and without settings for a +/// format it doesn't write. +pub(crate) fn drop_unread_screen_settings(settings: &mut Map) { + if let Some(rows) = settings.get_mut("formats").and_then(Value::as_array_mut) { + rows.iter_mut().filter_map(Value::as_object_mut).for_each(drop_unread_row_options); + } + if let Some(all) = settings.get_mut("settings").and_then(Value::as_object_mut) { + let formats = screen_formats(); + all.retain(|id, _| formats.contains(&id.as_str())); + all.values_mut().filter_map(Value::as_object_mut).for_each(drop_unread_row_options); + } +} + +/// A row with its size as the text the Scale field shows (`2x`, `100w`, `100h`, `72ppi`), without +/// the keys the export no longer reads. fn editable_row(row: &Value) -> Value { let num = |k: &str| row.get(k).and_then(Value::as_f64); let text = match (num("width"), num("height"), num("ppi"), row.get("scale")) { @@ -230,6 +249,7 @@ fn editable_row(row: &Value) -> Value { }; let mut r = row.clone(); if let Some(o) = r.as_object_mut() { + drop_unread_row_options(o); for k in ["width", "height", "ppi"] { o.remove(k); } @@ -575,16 +595,23 @@ fn tiles( toggled } -/// The checked assets that are still in the document, in panel order. -fn checked_assets(app: &VectorcraftApp, d: &Dialog) -> Vec { - let on: Vec = d.fields.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); - app.session.active().map(|st| st.doc.assets.iter().map(|a| a.id).filter(|id| on.contains(id)).collect()).unwrap_or_default() +/// The document's assets exported as pictures, in panel order: 3D assets export as mesh files from +/// the Asset Export panel (`assets.export3D`). +fn picture_assets(st: &vectorcraft_engine::DocState) -> Vec<&vectorcraft_doc::ExportAsset> { + st.doc.assets.iter().filter(|a| a.format_3d.is_none()).collect() } -/// The Assets tab: each asset's thumbnail with its checkbox and name, in a grid or a list. +/// The checked picture assets that are still in the document, in panel order. +fn checked_assets(app: &VectorcraftApp, d: &Dialog) -> Vec { + let on: Vec = d.fields.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); + app.session.active().map(|st| picture_assets(st).iter().map(|a| a.id).filter(|id| on.contains(id)).collect()).unwrap_or_default() +} + +/// The Assets tab: each picture asset's thumbnail with its checkbox and name, in a grid or a list. fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, list: bool) { let Some(st) = app.session.active() else { return }; - if st.doc.assets.is_empty() { + let shown = picture_assets(st); + if shown.is_empty() { ui.add_space(24.0); widgets::dim_label(ui, tl!("No assets yet. Collect art with Object › Collect for Export, or drag it into the Asset Export panel.")); return; @@ -592,10 +619,10 @@ fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, list: bool) { let mut on = checked_assets(app, d); let before = on.clone(); // Asked for until its row is in view: the dialog's first frame only measures it. - let scroll_to = d.fields.get("__scrollTo").and_then(Value::as_u64).filter(|id| st.doc.assets.iter().any(|a| a.id == *id)); + let scroll_to = d.fields.get("__scrollTo").and_then(Value::as_u64).filter(|id| shown.iter().any(|a| a.id == *id)); let mut in_view = scroll_to.is_none(); - let clicked = tiles(ui, "efs-assets", st.doc.assets.len(), list, |ui, i, r| { - let Some(a) = st.doc.assets.get(i) else { return (String::new(), String::new(), false) }; + let clicked = tiles(ui, "efs-assets", shown.len(), list, |ui, i, r| { + let Some(a) = shown.get(i) else { return (String::new(), String::new(), false) }; crate::panels::asset_export::paint_thumb(ui, st, a, r); if scroll_to == Some(a.id) { if ui.clip_rect().contains_rect(r) { @@ -607,7 +634,7 @@ fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, list: bool) { (a.name.clone(), a.name.clone(), on.contains(&a.id)) }); for i in clicked { - if let Some(a) = st.doc.assets.get(i) { + if let Some(a) = shown.get(i) { if on.contains(&a.id) { on.retain(|id| *id != a.id); } else { @@ -879,7 +906,11 @@ pub(super) fn apply_preset(d: &mut Dialog, preset: &Value) { d.fields.insert("formats".into(), json!(rows)); d.fields.insert("preset".into(), json!(p)); } - if let (Some(all), Some(given)) = (d.fields.get_mut("settings").and_then(Value::as_object_mut), st["settings"].as_object()) { + // Settings for a format the export doesn't write, and keys it no longer reads, are left out. + let mut given = Map::from_iter([("settings".to_string(), st["settings"].clone())]); + drop_unread_screen_settings(&mut given); + if let (Some(all), Some(given)) = (d.fields.get_mut("settings").and_then(Value::as_object_mut), given.get("settings").and_then(Value::as_object)) + { for (id, o) in given { match (all.get_mut(id).and_then(Value::as_object_mut), o.as_object()) { (Some(mine), Some(o)) => mine.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))), diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 8d4a68ed0..be4f9e80c 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -1109,10 +1109,11 @@ pub(super) fn bleed(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Un } } -/// A small grey caption above a field (New Document's details column). -pub(super) fn caption(ui: &mut egui::Ui, text: &str) { +/// A small grey caption above a field (New Document's details column) → its response, so the field +/// can be named by it. +pub(super) fn caption(ui: &mut egui::Ui, text: &str) -> egui::Response { let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new(tl!(text)).size(11.0).color(t.text_dim)); + ui.label(egui::RichText::new(tl!(text)).size(11.0).color(t.text_dim)) } #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs/import_pdf.rs b/crates/ui-egui/src/dialogs/import_pdf.rs index 9a2223744..1baa838d9 100644 --- a/crates/ui-egui/src/dialogs/import_pdf.rs +++ b/crates/ui-egui/src/dialogs/import_pdf.rs @@ -114,17 +114,19 @@ pub(super) fn show_preview(ui: &mut egui::Ui, tex: Option<&egui::TextureHandle>, /// Open `bytes` (or place them with the `file.place` params `place`) through the dialog when the /// file is a PDF (or `.ai`/`.ait`) with several pages or a password; false when there's nothing to -/// ask (the caller goes on), including a file that carries its VectorCraft document when opening. +/// ask (the caller goes on), including, when opening, a file that carries its VectorCraft document +/// and a `.ai`/`.ait` with its editing data. pub fn offer(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option, place: Option<&Value>) -> bool { - if !fileio::detect(name, bytes).is_some_and(|f| matches!(f.id, "pdf" | "ai" | "ait")) { + let Some(format) = fileio::detect(name, bytes).map(|f| f.id).filter(|id| matches!(*id, "pdf" | "ai" | "ait")) else { return false; - } + }; // A file carrying its VectorCraft document (a saved .ai, or a PDF saved with Preserve Editing) - // opens as that document, every artboard and layer: there are no pages to pick. Placing still - // asks, since a place takes one page. + // opens as that document, every artboard and layer: there are no pages to pick. So does a .ai + // or .ait with its editing data, which holds them all (#6135); a PDF with editing data still + // asks, as its pages are what it is. Placing still asks, since a place takes one page. // The file is read once for both checks. let opened = vectorcraft_pdf::open_pdf(bytes, None); - if place.is_none() && opened.as_ref().is_ok_and(|o| o.editing().is_some()) { + if place.is_none() && opened.as_ref().is_ok_and(|o| o.editing().is_some() || (format != "pdf" && o.has_illustrator_data())) { return false; } let (pages, locked) = match opened.map(|o| o.page_count()) { diff --git a/crates/ui-egui/src/dialogs/new_document.rs b/crates/ui-egui/src/dialogs/new_document.rs index 1a4c39389..982dd7c6f 100644 --- a/crates/ui-egui/src/dialogs/new_document.rs +++ b/crates/ui-egui/src/dialogs/new_document.rs @@ -358,8 +358,9 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut let top = ui.cursor().top(); ui.label(egui::RichText::new(tl!("PRESET DETAILS")).font(theme::semibold(11.0)).color(t.text_dim)); ui.add_space(8.0); + let caption = form::caption(ui, tl!("Document Name")); ui.horizontal(|ui| { - form::text(ui, d, "name", DETAILS - 50.0); + ui.label_inputs_by(caption.id, |ui| form::text(ui, d, "name", DETAILS - 50.0)); save_button(app, ui, d); }); if d.bool(SAVING) { @@ -367,16 +368,16 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut } ui.add_space(8.0); let unit = unit(d); - form::caption(ui, tl!("Width")); + let caption = form::caption(ui, tl!("Width")); ui.horizontal(|ui| { - form::length(ui, d, "width", unit, DETAILS - 124.0); - units_dropdown(ui, d, 112.0); + ui.label_inputs_by(caption.id, |ui| form::length(ui, d, "width", unit, DETAILS - 124.0)); + ui.name_inputs(tl!("Units"), |ui| units_dropdown(ui, d, 112.0)); }); ui.add_space(4.0); ui.horizontal_top(|ui| { ui.vertical(|ui| { - form::caption(ui, tl!("Height")); - form::length(ui, d, "height", unit, 104.0); + let caption = form::caption(ui, tl!("Height")); + ui.label_inputs_by(caption.id, |ui| form::length(ui, d, "height", unit, 104.0)); }); ui.add_space(6.0); ui.vertical(|ui| { @@ -385,16 +386,16 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut }); ui.add_space(6.0); ui.vertical(|ui| { - form::caption(ui, tl!("Artboards")); - artboards(ui, d, 76.0); + let caption = form::caption(ui, tl!("Artboards")); + ui.label_inputs_by(caption.id, |ui| artboards(ui, d, 76.0)); }); }); ui.add_space(8.0); form::caption(ui, tl!("Bleed")); form::bleed(ui, d, unit, 52.0); ui.add_space(8.0); - form::caption(ui, tl!("Background Contents")); - background(ui, d, DETAILS - 34.0); + let caption = form::caption(ui, tl!("Background Contents")); + ui.label_inputs_by(caption.id, |ui| background(ui, d, DETAILS - 34.0)); ui.add_space(8.0); advanced(ui, d, 110.0, DETAILS - 116.0); ui.add_space(12.0); @@ -426,9 +427,10 @@ fn save_button(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { /// Save Preset: the preset's name, Save and Cancel. fn save_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.add_space(4.0); - form::caption(ui, tl!("Preset Name")); + let caption = form::caption(ui, tl!("Preset Name")); ui.horizontal(|ui| { - let enter = form::text_edit(ui, d, "presetName", DETAILS - 140.0).lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + let name = ui.label_inputs_by(caption.id, |ui| form::text_edit(ui, d, "presetName", DETAILS - 140.0)); + let enter = name.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); if widgets::flat_button(ui, tl!("Save"), 54.0).clicked() || enter { let mut p: Map = PARAMS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect(); p.insert("name".into(), json!(d.str("presetName"))); @@ -876,6 +878,44 @@ mod tests { assert!(selected(&out), "the turned preset stays chosen: {:?}", field(&app, "preset")); } + /// The document's name field has a caption of its own under the Preset Details heading, and + /// the column stays inside the dialog's height with Save Preset and Advanced Options open. + #[test] + fn the_name_field_is_captioned_and_the_column_fits() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.newDialog", json!({})).unwrap(); + set(&mut app, "advanced", json!(true)); + set(&mut app, super::SAVING, json!(true)); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut time = 0.0; + let mut run = |app: &mut VectorcraftApp| { + time += 0.5; + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 1000.0))); + let raw = egui::RawInput { screen_rect, time: Some(time), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + out + }; + run(&mut app); + let out = run(&mut app); + fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut texts = vec![]; + out.shapes.iter().for_each(|c| walk(&c.shape, &mut texts)); + let at = |name: &str| texts.iter().find(|(t, _)| t == name).map(|(_, r)| *r).unwrap_or_else(|| panic!("{name} is missing: {texts:?}")); + let (heading, caption, value) = (at("PRESET DETAILS"), at("Document Name"), at("Untitled-1")); + assert!(heading.bottom() <= caption.top() && caption.bottom() <= value.top(), "{heading:?} {caption:?} {value:?}"); + assert!(value.bottom() <= at("Preset Name").top(), "the preset's own name field follows"); + let used = at("Create").bottom() - heading.top(); + assert!(used <= super::HEIGHT, "the column used {used}"); + } + #[test] fn a_preset_fills_the_details_and_create_makes_the_document() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index eef4787ee..76f71f3b4 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -6,19 +6,29 @@ //! declared parameter (fields named after them; `__plugin` is the plug-in id) with a live //! Preview, and OK keeps the run as one undo step (`plugin.run`). A filter without parameters runs //! 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 › +//! it runs in the background ([`crate::plugin_runs`]). A filter or live effect with commands of +//! its own is a submenu named after it, holding its item and then its commands; a command with +//! parameters opens the same dialog for them ([`open_command`]). 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; one with +//! parameters opens dialog `pluginImport` once the file is picked, and its Open button opens it. +//! +//! Dialog `pluginInstall` asks a person to approve a plug-in's permissions or heavy budget. What it +//! shows and installs is held by the app, not in its fields; a control request or synthetic input +//! can't confirm it. Enter confirms it only when OK has the keyboard focus. use std::sync::Arc; use serde_json::{Map, Value, json}; -use vectorcraft_plugins::{Budget, Kind, Manifest, Plugin, registry}; +use vectorcraft_engine::cmd::plugin::InstallApproval; +use vectorcraft_plugins::{Budget, Kind, Manifest, Plugin, permissions, registry}; -use super::{DialogSpec, form}; +use super::{DialogAction, DialogSpec, form}; use crate::VectorcraftApp; use crate::menus::Item; use crate::state::Dialog; +use crate::widgets; /// The dialog kind of plug-in filters. pub const KIND: &str = "plugin"; @@ -38,12 +48,30 @@ fn label(name: &str, has_params: bool) -> &'static str { registry::intern(&format!("{name}{dots}")) } -/// Object › Plug-ins: the installed object filters, then Install Plug-in… and Reload Plug-ins. +/// A filter or live effect `plugin`'s item `own`, as the menu lists it: alone, or for a plug-in +/// with commands of its own, a submenu named after it holding `own` and then its commands. Each +/// command runs its command id (`plugin..`), with an ellipsis when it has parameters, +/// whose dialog [`open_command`] opens. +fn with_commands(plugin: &Plugin, own: Item) -> Item { + let m = plugin.manifest(); + if m.commands.is_empty() { + return own; + } + let mut items = vec![own, Item::Sep]; + items.extend(m.commands.iter().map(|c| { + let id = registry::intern(&vectorcraft_engine::cmd::plugin::command_id(&m.id, &c.id)); + Item::Cmd(label(&c.shown_label(), !c.params.is_empty()), id, Value::Null) + })); + Item::Sub(registry::intern(&permissions::display_text(m.name.trim(), permissions::MAX_SHOWN_NAME)), items) +} + +/// Object › Plug-ins: the installed object filters (each with its commands, if it has any), then +/// Install Plug-in… and Reload Plug-ins. pub fn object_menu() -> Vec { let mut items: Vec = registry::list() .iter() .filter(|p| p.manifest().kind == Kind::Filter) - .map(|p| Item::Cmd(label(&p.manifest().name, !p.manifest().params.is_empty()), "plugin.dialog", json!({ "id": p.id() }))) + .map(|p| with_commands(p, Item::Cmd(label(&p.manifest().name, !p.manifest().params.is_empty()), "plugin.dialog", json!({ "id": p.id() })))) .collect(); if items.is_empty() { items.push(Item::Header(NO_FILTERS)); @@ -56,14 +84,32 @@ pub fn object_menu() -> Vec { 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. +/// 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, each with its commands if it has any (`None` +/// while none is installed). Effects without options apply at once, as built-in ones do. pub fn effect_menu() -> Option { let items: Vec = vectorcraft_effects::plugin_effects() .into_iter() - .map(|e| match e.defaults.as_object().is_some_and(|o| o.is_empty()) { - true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })), - false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })), + .map(|e| { + let own = match e.defaults.as_object().is_some_and(|o| o.is_empty()) { + true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })), + false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })), + }; + match vectorcraft_plugins::effect::plugin_id(e.id).and_then(registry::get) { + Some(plugin) => with_commands(&plugin, own), + None => own, + } }) .collect(); (!items.is_empty()).then_some(Item::Sub("Plug-ins", items)) @@ -79,6 +125,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() { @@ -113,8 +160,44 @@ fn described(ui: &mut egui::Ui, d: &Dialog) -> Option> { Some(plugin) } +/// Opens the dialog of plug-in command `id` (`plugin..`), given no params, +/// when it has parameters and a document is open: its fields at their defaults (`__command` is the +/// command id) with a live Preview, and OK keeps the run as one undo step. False for any other +/// command, which then runs as it was given. +pub fn open_command(app: &mut VectorcraftApp, id: &str) -> bool { + let Some((plugin, c)) = vectorcraft_engine::cmd::plugin::command_of(id) else { return false }; + if c.params.is_empty() || app.session.active().is_none() { + return false; + } + let mut fields = fields_for(&plugin.manifest().for_command(&c)); + fields.insert("__command".into(), json!(id)); + fields.insert("__label".into(), json!(c.shown_label())); + fields.insert("preview".into(), json!(true)); + app.ui.dialog = Some(Dialog { kind: KIND.into(), fields }); + true +} + +/// The plug-in command dialog `d` is for (`__command`), and the manifest it runs with; `None` for +/// a filter's own dialog. +fn command_of(d: &Dialog) -> Option { + let (plugin, c) = vectorcraft_engine::cmd::plugin::command_of(&d.str("__command"))?; + Some(plugin.manifest().for_command(&c)) +} + +/// The command dialog `d` runs: its plug-in command, or `plugin.run`. +fn command(d: &Dialog) -> String { + match d.str("__command") { + c if c.is_empty() => CMD.to_string(), + c => c, + } +} + +/// The params of the command dialog `d` runs ([`command`]). fn params(d: &Dialog) -> Value { - json!({"id": d.str("__plugin"), "params": form::params(d)}) + match d.str("__command").is_empty() { + true => json!({"id": d.str("__plugin"), "params": form::params(d)}), + false => json!({"params": form::params(d)}), + } } /// Runs filter `plugin` with `plugin.run`'s params `p`: at once, or in the background when it is @@ -126,15 +209,20 @@ fn run(app: &mut VectorcraftApp, plugin: &Plugin, p: Value) -> Result bool { let Some(plugin) = described(ui, d) else { return true }; - form::schema_fields(app, ui, d, plugin.manifest()); + let run_with = command_of(d); + if run_with.is_none() && !d.str("__command").is_empty() { + return true; + } + form::schema_fields(app, ui, d, run_with.as_ref().unwrap_or(plugin.manifest())); match plugin.manifest().budget { Budget::Light => { let (label, p) = (d.str("__label"), params(d)); - form::preview(app, ui, d, &label, CMD, p); + form::preview(app, ui, d, &label, &command(d), p); } // No Preview: each run may take minutes. Budget::Heavy => { @@ -157,7 +245,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } r } - _ => form::commit_preview(app, CMD, params(d)), + _ => form::commit_preview(app, &command(d), params(d)), } } @@ -197,3 +285,102 @@ 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`, and `picked` for a file from the +/// picker, which is read off the UI thread), 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; + let picked = p["picked"] == true; + crate::io::open_with_on(app, &p, picked && crate::opening::available()) +} + +/// The dialog kind of the approval of a plug-in install. +pub const INSTALL_KIND: &str = "pluginInstall"; + +/// A plug-in install waiting for a person's answer. +pub(crate) struct PendingInstall { + /// The question and the lines under it, in the UI language. + pub message: String, + pub detail: String, + /// `plugin.install`'s params for OK. + pub params: Value, + /// What OK approves: the module the question is about. + pub approval: InstallApproval, +} + +/// No OK in the shared footer: the OK in its body is a `Sense::click` button, which Enter clicks +/// only when it has the keyboard focus. +pub(super) const INSTALL_SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Install Plug-in…").trim_end_matches('…').to_string(), + body_with_actions: Some(install_body), + confirm: install_confirm, + ok: None, + max_width: Some(420.0), + ..DialogSpec::FORM +}; + +/// Asks a person whether to install what `pending` describes. → `{pending}`. +pub(crate) fn ask_install(app: &mut VectorcraftApp, pending: PendingInstall) -> Value { + app.pending_plugin_install = Some(pending); + app.ui.dialog = Some(Dialog::new(INSTALL_KIND, json!({}))); + json!({ "pending": INSTALL_KIND }) +} + +fn install_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> DialogAction { + let Some(pending) = app.pending_plugin_install.as_ref() else { return DialogAction::Cancel }; + ui.label(egui::RichText::new(&pending.message).strong()); + ui.add_space(8.0); + ui.label(egui::RichText::new(&pending.detail).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + ui.add_space(16.0); + let mut action = DialogAction::None; + let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y); + ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::primary_button_enabled(ui, "OK", true).clicked() { + action = DialogAction::Confirm; + } + ui.add_space(8.0); + if widgets::secondary_button(ui, "Cancel").clicked() { + action = DialogAction::Cancel; + } + }); + action +} + +/// OK: installs the module the dialog showed, if a person pressed it; automation is refused and the +/// dialog stays open. +fn install_confirm(app: &mut VectorcraftApp, _: &Dialog) -> Result { + if app.automation_is_acting() { + return Err("Only the person at the keyboard can approve installing a plug-in".into()); + } + app.ui.dialog = None; + let Some(pending) = app.pending_plugin_install.take() else { return Ok(Value::Null) }; + app.session.approve_plugin_install(pending.approval); + let r = app.run("plugin.install", pending.params); + app.session.clear_plugin_install_approval(); + let r = r?; + crate::io::report_installed(app, &r); + Ok(r) +} diff --git a/crates/ui-egui/src/dialogs/print.rs b/crates/ui-egui/src/dialogs/print.rs index 523c42d06..807beed25 100644 --- a/crates/ui-egui/src/dialogs/print.rs +++ b/crates/ui-egui/src/dialogs/print.rs @@ -427,22 +427,32 @@ fn page_preview(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, pv: } let coeffs: Vec = sheet["transform"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); let clip = ui.painter().with_clip_rect(page); + // With copies per sheet, `copies` holds each copy's artboard on the page, and `moves` holds + // each copy's move on screen from the first. + let copies: Vec = sheet["copies"].as_array().map(|a| a.iter().filter_map(rect_of).collect()).unwrap_or_default(); + let moves: Vec = match copies.first() { + Some(first) => copies.iter().map(|r| egui::vec2((r.x0 - first.x0) as f32, (r.y0 - first.y0) as f32) * fit).collect(), + None => vec![egui::Vec2::ZERO], + }; if let ([a, b, c, dd, e, f], Some(area)) = (coeffs.as_slice(), rect_of(&sheet["area"])) { let px = (f64::from(page.width().max(page.height()) * ui.ctx().pixels_per_point())).min(1024.0); let layers = choice::(d, "printLayers").unwrap_or(PrintLayers::VisiblePrintable); if let Some(tex) = art_texture(app, ui.ctx(), layers, area, px) { let at = |x: f64, y: f64| to_screen(a * x + c * y + e, b * x + dd * y + f) + offset; let mut mesh = egui::Mesh::with_texture(tex.id()); - for (p, uv) in [ - ((area.x0, area.y0), (0.0, 0.0)), - ((area.x1, area.y0), (1.0, 0.0)), - ((area.x1, area.y1), (1.0, 1.0)), - ((area.x0, area.y1), (0.0, 1.0)), - ] { - mesh.vertices.push(egui::epaint::Vertex { pos: at(p.0, p.1), uv: egui::pos2(uv.0, uv.1), color: egui::Color32::WHITE }); + for m in &moves { + let first = mesh.vertices.len() as u32; + for (p, uv) in [ + ((area.x0, area.y0), (0.0, 0.0)), + ((area.x1, area.y0), (1.0, 0.0)), + ((area.x1, area.y1), (1.0, 1.0)), + ((area.x0, area.y1), (0.0, 1.0)), + ] { + mesh.vertices.push(egui::epaint::Vertex { pos: at(p.0, p.1) + *m, uv: egui::pos2(uv.0, uv.1), color: egui::Color32::WHITE }); + } + mesh.add_triangle(first, first + 1, first + 2); + mesh.add_triangle(first, first + 2, first + 3); } - mesh.add_triangle(0, 1, 2); - mesh.add_triangle(0, 2, 3); clip.add(mesh); } } @@ -460,7 +470,9 @@ fn page_preview(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, pv: let corners = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; clip.extend(egui::Shape::dashed_line(&corners, stroke, 3.0, 2.0)); } - if let Some(trim) = rect_of(&sheet["trim"]) { + // The preview strokes each copy's artboard edge, or the trim box without copies per sheet. + let trims = if copies.is_empty() { rect_of(&sheet["trim"]).into_iter().collect() } else { copies }; + for trim in trims { let r = egui::Rect::from_min_max(to_screen(trim.x0, trim.y0), to_screen(trim.x1, trim.y1)).translate(offset); clip.rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Middle); } @@ -660,6 +672,9 @@ fn general(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { if tiles { note(ui, tl!("Tiles are numbered across, then down; leave the range empty for every tile.")); } + flag(ui, d, "copiesPerSheet", tl!("Copies per Sheet (as many as fit, in a grid)"), !tiles); + let per_sheet = get(d, "copiesPerSheet").as_bool() == Some(true); + row(ui, tl!("Gutter:"), |ui| length(ui, d, "gutter", unit, !tiles && per_sheet)); } fn output(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result) { 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/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index f3c7d7e35..6ca2e5771 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -1,11 +1,12 @@ -//! Compact controls for the 3D and Materials effects: Extrude & Bevel, Revolve, Inflate and Rotate. -//! The object and the rotation and light gizmos live on the artboard. All document changes run -//! through the effect dialog's existing preview/commit commands. +//! Compact controls for the 3D and Materials effects: Extrude & Bevel, Revolve, Inflate and Rotate, +//! and Extrude & Bevel (Classic), whose own controls are in `three_d_classic`. The object and the +//! rotation and light gizmos live on the artboard. All document changes run through the effect +//! dialog's existing preview/commit commands. use egui::{Align2, FontId, Id, Pos2, Sense, Stroke, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::Unit; -use vectorcraft_effects::{EXTRUDE, INFLATE, REVOLVE, ROTATION_PRESETS}; +use vectorcraft_effects::{CLASSIC_EXTRUDE, EXTRUDE, INFLATE, REVOLVE, ROTATION_PRESETS}; use crate::state::Dialog; use crate::theme::Tokens; @@ -140,14 +141,15 @@ pub(crate) fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) } /// `s` as the 3D effects mean it (a solid's cap, not a stroke's; a bevelled edge, not a join). -fn c3(s: &'static str) -> &'static str { +pub(super) fn c3(s: &'static str) -> &'static str { crate::i18n::tr_ctx(crate::i18n::current(), "3d", s) } -/// Extrude & Bevel and Inflate: Object, Rotation and Lighting tabs (Rotate has no Object tab). -/// The rotation gizmo shows on the first two, the light gizmo on Lighting. +/// Extrude & Bevel, Inflate and Extrude & Bevel (Classic): Object, Rotation and Lighting tabs +/// (Rotate has no Object tab). The rotation gizmo shows on the first two, the light gizmo on +/// Lighting. fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: &str) { - let object = id == EXTRUDE || id == INFLATE; + let object = id == EXTRUDE || id == INFLATE || id == CLASSIC_EXTRUDE; let mut tabs = vec![]; if object { tabs.push(tl!("Object")); @@ -167,10 +169,16 @@ fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: & } ui.add_space(8.0); if super::revolve_gizmo::lighting(d) { - lighting_controls(ui, d); + if id == CLASSIC_EXTRUDE { + super::three_d_classic::surface_controls(ui, d); + } else { + lighting_controls(ui, d); + } } else if object && d.str("__tab") != "rotation" { let unit = app.session.general_unit(); - if id == EXTRUDE { + if id == CLASSIC_EXTRUDE { + super::three_d_classic::object_controls(ui, d, unit); + } else if id == EXTRUDE { extrude_controls(ui, d, unit); } else { heading(ui, tl!("Object")); @@ -249,7 +257,7 @@ fn view_controls(ui: &mut egui::Ui, d: &mut Dialog) { } /// A dropdown of `options` (value, label) for `key`, with its reset dot. -fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, options: &[(Value, &str)]) { +pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, options: &[(Value, &str)]) { let id = d.str("__effect"); let default = vectorcraft_effects::default_params(&id).and_then(|p| p.get(key).cloned()).unwrap_or(Value::Null); // A mixed setting (null) shows its default, with its reset dot filled. @@ -283,7 +291,7 @@ fn flag(d: &Dialog, key: &str, default: bool) -> Option { } /// A checkbox for `key`, with its reset dot. A mixed setting shows a dash, and a click turns it on. -fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, default: bool) { +pub(super) fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, default: bool) { ui.horizontal(|ui| { let on = flag(d, key, default); if widgets::check3(ui, name, on, ui.is_enabled()) { @@ -386,11 +394,11 @@ pub(super) fn light_options(d: &Dialog) -> vectorcraft_three_d::Revolve { vectorcraft_effects::revolve_options(&super::form::params(&selected_light(d))) } -fn light_record(d: &Dialog) -> Value { +pub(super) fn light_record(d: &Dialog) -> Value { Value::Object(LIGHT_KEYS.iter().filter_map(|(key, field)| d.fields.get(*field).map(|v| ((*key).into(), v.clone()))).collect()) } -fn store_selected(d: &mut Dialog, selected: &Dialog) { +pub(super) fn store_selected(d: &mut Dialog, selected: &Dialog) { if let Some(list) = explicit_lights(d) { let index = light_index(d, list.len()); let mut list = list.clone(); @@ -413,7 +421,7 @@ pub(super) fn set_light_value(d: &mut Dialog, key: &str, value: Value) { store_selected(d, &selected); } -fn light_list(ui: &mut egui::Ui, d: &mut Dialog) { +pub(super) fn light_list(ui: &mut egui::Ui, d: &mut Dialog) { let mut list = explicit_lights(d).cloned().unwrap_or_else(|| vec![light_record(d)]); let mut index = light_index(d, list.len()); let names: Vec<_> = (1..=list.len()).map(|i| format!("{} {i}", c3("Light"))).collect(); @@ -483,12 +491,12 @@ fn light_color(ui: &mut egui::Ui, d: &mut Dialog) { }); } -fn heading(ui: &mut egui::Ui, text: &str) { +pub(super) fn heading(ui: &mut egui::Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(text).font(crate::theme::semibold(13.0)).color(t.text)); } -fn label(ui: &mut egui::Ui, name: &str) { +pub(super) fn label(ui: &mut egui::Ui, name: &str) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(LABEL_W, 24.0), Sense::hover()); ui.put(r, egui::Label::new(egui::RichText::new(name).size(12.5).color(t.text)).halign(egui::Align::Min).truncate()).on_hover_text(name); @@ -501,7 +509,7 @@ fn rail_width(ui: &egui::Ui) -> f32 { /// A value can be typed, dragged, scrubbed by its label, or moved on its slider. #[allow(clippy::too_many_arguments)] -fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, f64), suffix: &str, decimals: usize, unit: Option) { +pub(super) fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, f64), suffix: &str, decimals: usize, unit: Option) { let default = vectorcraft_effects::default_params(&d.str("__effect")).and_then(|p| p.get(key).and_then(Value::as_f64)).unwrap_or(0.0); let from = |n| unit.map_or(n, |u| u.from_pt(n)); let to = |n| unit.map_or(n, |u| u.to_pt(n)); @@ -591,7 +599,7 @@ fn dot(ui: &egui::Ui, p: Pos2, changed: bool) { } } -fn reset_dot(ui: &mut egui::Ui, key: &str, changed: bool, default: &str) -> bool { +pub(super) fn reset_dot(ui: &mut egui::Ui, key: &str, changed: bool, default: &str) -> bool { let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover()); let response = ui.interact(r, Id::unique(("revolve-reset", key)), Sense::click()); response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Reset to default"))); diff --git a/crates/ui-egui/src/dialogs/revolve/tests.rs b/crates/ui-egui/src/dialogs/revolve/tests.rs index bbaea5255..103fbe6ad 100644 --- a/crates/ui-egui/src/dialogs/revolve/tests.rs +++ b/crates/ui-egui/src/dialogs/revolve/tests.rs @@ -297,6 +297,68 @@ fn the_other_3d_effects_show_their_options_and_position_presets() { } } +/// Extrude & Bevel (Classic): its extrude and bevel options on the Object tab, Position on the +/// Rotation tab, and the surface with its lights, highlight, blend steps and shading color on the +/// Lighting tab; OK keeps the edit as one undo step. +#[test] +fn classic_extrude_bevel_shows_its_options_and_applies_as_one_step() { + use vectorcraft_effects::CLASSIC_EXTRUDE; + let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", CLASSIC_EXTRUDE, vec2(1400.0, 1000.0)); + let before = h.app.session.doc().unwrap().doc.clone(); + h.settle(); + for text in ["Depth", "Cap", "Bevel Shape", "Height", "Bevel Inside"] { + h.rect(text); + } + assert!(!h.texts.iter().any(|(t, _)| t == "Twist" || t == "Taper" || t == "Width"), "the newer effect's options stay out"); + assert!(!h.ctx.read_response(Id::unique(("revolve-reset", "bevelHeight"))).unwrap().enabled(), "no bevel: its height is off"); + h.app.ui.dialog.as_mut().unwrap().fields.insert("__tab".into(), json!("rotation")); + h.settle(); + for text in ["Presets", "Rotation X", "Perspective", "Off-Axis Front"] { + h.rect(text); + } + h.click(h.rect("Lighting").center()); + h.settle(); + for text in [ + "Surface", + "Plastic", + "Light Intensity", + "Ambient Light", + "Highlight Intensity", + "Highlight Size", + "Blend Steps", + "Shading Color", + "Draw Hidden Faces", + ] { + h.rect(text); + } + assert!(h.ctx.read_response(Id::unique(("revolve-reset", "highlightSize"))).unwrap().enabled()); + // The light gizmo is on for a lit surface. + assert_eq!(dialogs::revolve_gizmo::hint(h.app.ui.dialog.as_ref().unwrap()), "Drag the light, or its rings. Shift snaps to 15°."); + // Diffuse has no highlight; Wireframe and No Shading take no light. + h.app.ui.dialog.as_mut().unwrap().fields.insert("surface".into(), json!("diffuse")); + h.settle(); + assert!(!h.ctx.read_response(Id::unique(("revolve-reset", "highlightSize"))).unwrap().enabled()); + assert!(h.ctx.read_response(Id::unique(("revolve-reset", "ambient"))).unwrap().enabled()); + h.app.ui.dialog.as_mut().unwrap().fields.insert("surface".into(), json!("wireframe")); + h.settle(); + assert!(!h.ctx.read_response(Id::unique(("revolve-reset", "ambient"))).unwrap().enabled()); + assert!(h.ctx.read_response(Id::unique(("revolve-reset", "drawHiddenFaces"))).unwrap().enabled()); + assert_eq!(dialogs::revolve_gizmo::hint(h.app.ui.dialog.as_ref().unwrap()), "Drag values or labels. Double-click a value to type."); + // A custom shading color shows its color row. + let d = h.app.ui.dialog.as_mut().unwrap(); + d.fields.insert("surface".into(), json!("diffuse")); + d.fields.insert("shadingColor".into(), json!("custom")); + h.settle(); + h.rect("#000000"); + dialogs::confirm(&mut h.app).unwrap(); + let state = h.app.session.doc().unwrap(); + let fx = &state.doc.node(state.selection.objects[0]).unwrap().appearance.effects[0]; + assert_eq!(fx.id, CLASSIC_EXTRUDE); + assert_eq!((fx.params["surface"].clone(), fx.params["shadingColor"].clone()), (json!("diffuse"), json!("custom"))); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc, before); +} + #[test] fn extrude_bevel_options_enable_with_a_bevel_shape() { let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", vectorcraft_effects::EXTRUDE, vec2(1400.0, 1000.0)); diff --git a/crates/ui-egui/src/dialogs/revolve_gizmo.rs b/crates/ui-egui/src/dialogs/revolve_gizmo.rs index c9194d5d0..6ef51d38e 100644 --- a/crates/ui-egui/src/dialogs/revolve_gizmo.rs +++ b/crates/ui-egui/src/dialogs/revolve_gizmo.rs @@ -20,8 +20,18 @@ pub(crate) fn lighting(d: &Dialog) -> bool { d.str("__gizmo") == "light" } +/// Does the effect take light: Shading on, or for Extrude & Bevel (Classic) a Diffuse or Plastic +/// surface? +fn lit(d: &Dialog) -> bool { + if d.str("__effect") == vectorcraft_effects::CLASSIC_EXTRUDE { + matches!(d.str("surface").as_str(), "diffuse" | "plastic") + } else { + d.bool("shade") + } +} + pub(crate) fn hint(d: &Dialog) -> &'static str { - if !d.bool("preview") || (lighting(d) && !d.bool("shade")) { + if !d.bool("preview") || (lighting(d) && !lit(d)) { tl!("Drag values or labels. Double-click a value to type.") } else if lighting(d) { tl!("Drag the light, or its rings. Shift snaps to 15°.") @@ -190,7 +200,7 @@ fn snap_height(height: f64, snap: bool) -> f64 { pub(super) fn show(app: &VectorcraftApp, ctx: &egui::Context, d: &mut Dialog) { let light_mode = lighting(d); - if !d.bool("preview") || (light_mode && !d.bool("shade")) { + if !d.bool("preview") || (light_mode && !lit(d)) { clear(ctx); return; } diff --git a/crates/ui-egui/src/dialogs/safe_mode.rs b/crates/ui-egui/src/dialogs/safe_mode.rs new file mode 100644 index 000000000..99ea560bb --- /dev/null +++ b/crates/ui-egui/src/dialogs/safe_mode.rs @@ -0,0 +1,211 @@ +//! Safe Mode in the app: the choice after a launch that stopped while a plug-in was loading +//! ([`ask_about_stopped`]: Disable and Continue, Start Normally, or Start in Safe Mode), and Help › +//! Safe Mode ([`open`]), which lists the disabled plug-ins with Enable on Re-launch. Safe Mode +//! itself, the disabled list and the launch marker are `vectorcraft_plugins::safe_mode`'s. + +use serde_json::{Value, json}; +use vectorcraft_plugins::safe_mode::{self, Item}; + +use super::DialogSpec; +use crate::VectorcraftApp; +use crate::state::Dialog; +use crate::theme::Tokens; + +/// Dialog kind of the choice after a launch that stopped. Fields: `item` (`{id, file, name, label}`) +/// and `normal` (Start Normally instead of Disable and Continue). Cancel starts in Safe Mode. +pub const STOPPED: &str = "pluginStopped"; + +/// Dialog kind of Help › Safe Mode. Fields: `items` (`[{id, file, name, label, enable}]`, the +/// disabled plug-ins, `enable` being Enable on Re-launch). +pub const KIND: &str = "safeMode"; + +/// Before any plug-in loads (desktop): starts this launch's marker in `marker_dir` (none: no +/// marker), and turns Safe Mode on for `--safe-mode` (`flag`), or until the user chooses after a +/// launch that stopped while a plug-in was loading → that plug-in, for [`after_start`]. With `flag` +/// nothing is asked, and the plug-in stays pending (`plugin.safeMode`'s `pendingItem`): the next +/// launch asks about it. +#[cfg(not(target_arch = "wasm32"))] +pub fn begin_launch(marker_dir: Option<&std::path::Path>, flag: bool) -> Option { + let stopped = marker_dir.and_then(safe_mode::begin_launch); + if flag { + safe_mode::set_active(Some(safe_mode::Source::Flag)); + return None; + } + if stopped.is_some() { + safe_mode::set_active(Some(safe_mode::Source::Startup)); + } + stopped +} + +/// The app is quitting normally: its launch marker goes. +#[cfg(not(target_arch = "wasm32"))] +pub fn end_launch() { + safe_mode::end_launch(); +} + +/// Once the app is made: the choice about `stopped` ([`begin_launch`]), or in Safe Mode a status +/// line saying so. +pub fn after_start(app: &mut VectorcraftApp, stopped: Option) { + match stopped { + Some(item) => ask_about_stopped(app, &item), + None if safe_mode::active() => app.status(vectorcraft_plugins::Error::SafeMode.to_string()), + None => {} + } +} + +/// Ask what to do about `item`, the plug-in the last launch was loading when it stopped. Safe Mode +/// is on until the user chooses. A dialog that takes the choice's place leaves it waiting: it opens +/// again once no dialog is open ([`reopen`]). +pub fn ask_about_stopped(app: &mut VectorcraftApp, item: &Item) { + let item = item.to_json(); + app.ui.stopped_plugin = Some(item.clone()); + app.ui.dialog = Some(Dialog::new(STOPPED, json!({ "item": item }))); +} + +/// With no dialog open, the choice about a stopped plug-in that another dialog replaced opens again. +pub(super) fn reopen(app: &mut VectorcraftApp) { + if app.ui.dialog.is_none() + && let Some(item) = app.ui.stopped_plugin.clone() + { + app.ui.dialog = Some(Dialog::new(STOPPED, json!({ "item": item }))); + } +} + +/// The choice's Cancel, Start in Safe Mode: Safe Mode stays on for this launch, and the plug-in +/// stays pending for the next one. +pub(super) fn cancel_stopped(app: &mut VectorcraftApp) { + app.ui.stopped_plugin = None; +} + +/// The choice about a stopped plug-in is waiting for the user (shown, or replaced by another +/// dialog): Safe Mode is on, and nothing has loaded yet. +pub fn choosing(app: &VectorcraftApp) -> bool { + app.ui.stopped_plugin.is_some() +} + +/// Safe Mode is on for the rest of this launch: `--safe-mode`, or Start in Safe Mode chosen. +pub fn staying_on(app: &VectorcraftApp) -> bool { + safe_mode::active() && !choosing(app) +} + +/// The item a dialog field holds. +fn item_of(v: &Value) -> Item { + let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string); + Item { id: s("id"), file: s("file"), name: s("name") } +} + +/// The choice's Disable and Continue (OK) or Start Normally: Safe Mode ends and the plug-ins load, +/// without the stopped one for Disable and Continue (kept off for later launches too). +fn confirm_stopped(app: &mut VectorcraftApp, d: &Dialog) -> Result { + app.ui.dialog = None; + app.ui.stopped_plugin = None; + let item = item_of(d.fields.get("item").unwrap_or(&Value::Null)); + // When the disabled plug-in can't be saved for the next launch, that launch asks again. + let saved = d.bool("normal") || (safe_mode::disable(item) && app.session.store_disabled_plugins()); + if saved { + safe_mode::forget_previous(); + } else { + not_saved(app); + } + safe_mode::set_active(None); + vectorcraft_engine::cmd::plugin::load_prefs_folder(&mut app.session); + Ok(Value::Null) +} + +/// Help › Safe Mode: the disabled plug-ins, none ticked for Enable on Re-launch. +pub fn open(app: &mut VectorcraftApp) -> Result { + let items: Vec = safe_mode::disabled() + .iter() + .map(|i| { + let mut v = i.to_json(); + v["enable"] = json!(false); + v + }) + .collect(); + app.ui.dialog = Some(Dialog::new(KIND, json!({ "items": items }))); + Ok(Value::Null) +} + +/// Help › Safe Mode's OK: each ticked plug-in loads again from the next launch +/// (`plugin.setEnabled`). +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + app.ui.dialog = None; + let mut errors = vec![]; + let mut unsaved = false; + for v in d.fields.get("items").and_then(Value::as_array).into_iter().flatten().filter(|v| v["enable"] == true) { + let p = match (v["id"].as_str(), v["file"].as_str()) { + (Some(id), _) => json!({"id": id, "enabled": true}), + (None, Some(file)) => json!({"file": file, "enabled": true}), + (None, None) => continue, + }; + match app.run("plugin.setEnabled", p) { + Ok(r) => unsaved |= r["saved"] == false, + Err(e) => errors.push(e), + } + } + if unsaved { + not_saved(app); + } + if errors.is_empty() { Ok(Value::Null) } else { Err(errors.join("; ")) } +} + +/// The disabled plug-ins couldn't be written to the plug-in folder: the next launch doesn't have +/// the change, which goes with the next change that saves. +fn not_saved(app: &mut VectorcraftApp) { + app.status("Couldn't save the disabled plug-ins for the next launch"); +} + +pub(super) const STOPPED_SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Safe Mode").into(), + body: |_, ui, d| { + let t = Tokens::get(ui.ctx()); + let name = item_of(d.fields.get("item").unwrap_or(&Value::Null)).label(); + let text = crate::i18n::fmt( + tl!("The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it."), + &[("name", &name)], + ); + ui.label(egui::RichText::new(text).color(t.text)); + false + }, + confirm: confirm_stopped, + ok: Some("Disable and Continue"), + cancel: Some("Start in Safe Mode"), + alternate: Some(("Start Normally", "normal")), + min_width: 380.0, + max_width: Some(520.0), + ..DialogSpec::FORM +}; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Safe Mode").into(), + body: |_, ui, d| { + let t = Tokens::get(ui.ctx()); + if safe_mode::active() { + ui.label(egui::RichText::new(tl!("VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally.")).color(t.text)); + ui.add_space(8.0); + } + ui.label(egui::RichText::new(tl!("Disabled plug-ins")).color(t.text_dim)); + let Some(items) = d.fields.get_mut("items").and_then(Value::as_array_mut).filter(|i| !i.is_empty()) else { + ui.label(egui::RichText::new(tl!("No plug-ins are disabled.")).color(t.text_dim)); + return false; + }; + egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| { + for (k, v) in items.iter_mut().enumerate() { + ui.horizontal(|ui| { + // A name made safe to show: control and reordering characters removed, bounded. + let label = safe_mode::display_name(v["label"].as_str().unwrap_or_default()); + ui.label(egui::RichText::new(label).color(t.text)); + let mut on = v["enable"] == true; + if ui.push_id(k, |ui| ui.checkbox(&mut on, tl!("Enable on Re-launch"))).inner.changed() { + v["enable"] = json!(on); + } + }); + } + }); + false + }, + confirm, + min_width: 380.0, + max_width: Some(520.0), + ..DialogSpec::FORM +}; diff --git a/crates/ui-egui/src/dialogs/save_options/tests.rs b/crates/ui-egui/src/dialogs/save_options/tests.rs index 1f33575e7..959079968 100644 --- a/crates/ui-egui/src/dialogs/save_options/tests.rs +++ b/crates/ui-egui/src/dialogs/save_options/tests.rs @@ -221,6 +221,7 @@ fn save_a_copy_and_template_suggest_their_names() { assert_eq!(picks.borrow()[0].name, "Untitled-1 copy.vectorcraft"); assert!(app.ui.dialog.is_none(), "the native format has no options"); assert!(vectorcraft_format::sniff(&written.borrow()[0].1)); + assert!(vectorcraft_format::pdf_content(&written.borrow()[0].1).is_some()); assert!(app.session.active().unwrap().path.is_none() && app.session.active().unwrap().is_dirty()); app.run("file.saveAsTemplate", json!({})).unwrap(); let pick = &picks.borrow()[1]; diff --git a/crates/ui-egui/src/dialogs/shortcut_import.rs b/crates/ui-egui/src/dialogs/shortcut_import.rs index c65b4e602..051669523 100644 --- a/crates/ui-egui/src/dialogs/shortcut_import.rs +++ b/crates/ui-egui/src/dialogs/shortcut_import.rs @@ -19,7 +19,7 @@ //! at first when a code is not known), and `__query` and `__attention` (the filter of the rows shown). use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; -use std::sync::OnceLock; +use std::sync::{Arc, Mutex}; use craft_keymap::import::namematch::{Candidate, Matcher, Proposal}; use craft_keymap::import::{MAX_IMPORT_TEXT, MAX_PATH_SEGMENTS}; @@ -363,10 +363,12 @@ fn single_key(chord: &str) -> bool { } /// What a name can stand for: Keyboard Shortcuts' entries, by their English labels and the labels in -/// the interface language. +/// the interface language. A filter plug-in's row is left out: a row of the file reaches it only +/// when the user picks it as the target. pub fn candidates() -> Vec { shortcut_editor::entries() .iter() + .filter(|e| !e.key.starts_with("plugin:")) .map(|e| { let shown = tl!(&e.label); let labels = if shown == e.label { vec![e.label.clone()] } else { vec![e.label.clone(), shown.to_string()] }; @@ -375,10 +377,17 @@ pub fn candidates() -> Vec { .collect() } -/// Keyboard Shortcuts' entry `key`. -fn entry(key: &str) -> Option<&'static Entry> { - static BY_KEY: OnceLock> = OnceLock::new(); - BY_KEY.get_or_init(|| shortcut_editor::entries().iter().map(|e| (e.key.as_str(), e)).collect()).get(key).copied() +/// Keyboard Shortcuts' entry `key`, among the entries for the plug-ins installed now. +fn entry(key: &str) -> Option { + type Index = (Arc<[Entry]>, HashMap); + static BY_KEY: Mutex> = Mutex::new(None); + let entries = shortcut_editor::entries(); + let mut by = BY_KEY.lock().unwrap_or_else(|e| e.into_inner()); + if by.as_ref().is_none_or(|(e, _)| !Arc::ptr_eq(e, &entries)) { + let index = entries.iter().enumerate().map(|(i, e)| (e.key.clone(), i)).collect(); + *by = Some((entries, index)); + } + by.as_ref().and_then(|(e, index)| index.get(key).and_then(|&i| e.get(i)).cloned()) } /// A tool, command or panel that Keyboard Shortcuts can give a shortcut. @@ -388,7 +397,7 @@ fn known(key: &str) -> bool { /// How Keyboard Shortcuts names entry `e`: a tool by its label, the others after their menu. fn shown(e: &Entry) -> String { - if e.is_tool || e.group.is_empty() { tl!(&e.label).to_string() } else { format!("{} › {}", tl!(&e.group), tl!(&e.label)) } + shortcut_editor::shown_name(e) } /// What a row's target shows. @@ -396,7 +405,7 @@ fn target_label(target: Option<&str>) -> String { match target { None => tl!("Choose…").to_string(), Some("") => tl!("No equivalent").to_string(), - Some(t) => entry(t).map_or_else(|| t.to_string(), shown), + Some(t) => entry(t).map_or_else(|| t.to_string(), |e| shown(&e)), } } @@ -508,7 +517,7 @@ pub fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { shortcut_editor::open(app); } if let Some(k) = app.ui.dialog.as_mut() { - k.fields.insert("overrides".into(), crate::keymap::stored::to_value(km.overrides())); + k.fields.insert("overrides".into(), crate::keymap::stored::to_value(&shortcut_editor::saved_overrides(km.overrides(), &ov))); k.fields.insert("set".into(), json!(set)); k.fields.insert("__message".into(), json!(tl!("Imported. Press OK to keep the imported set."))); } @@ -934,6 +943,7 @@ pub(super) fn target_picker(ui: &mut egui::Ui, i: usize, r: &Row) -> Option Option = if q.is_empty() { for e in r.choices.iter().filter_map(|c| entry(c)) { - picked = option(ui, &e.key, &shown(e)).or(picked); + picked = option(ui, &e.key, &shown(&e)).or(picked); } ui.separator(); - shortcut_editor::entries().iter().collect() + all.iter().collect() } else { - shortcut_editor::entries().iter().filter(|e| shown(e).to_lowercase().contains(&q) || e.key.to_lowercase().contains(&q)).collect() + all.iter().filter(|e| shown(e).to_lowercase().contains(&q) || e.key.to_lowercase().contains(&q)).collect() }; ui.data_mut(|d| d.insert_temp(header_id, ui.min_rect().height())); (list, picked) diff --git a/crates/ui-egui/src/dialogs/svg_filter.rs b/crates/ui-egui/src/dialogs/svg_filter.rs new file mode 100644 index 000000000..af6849de4 --- /dev/null +++ b/crates/ui-egui/src/dialogs/svg_filter.rs @@ -0,0 +1,453 @@ +//! Apply SVG Filter (Effect › SVG Filters › Apply SVG Filter…): the document's SVG filters in a +//! list. OK applies the selected one to the selection as a live effect (`svgFilter.apply`), or, +//! opened from the Appearance panel for an applied SVG filter, puts it in that effect's place +//! (`effect.setParams`). New and Edit open the filter's code under the list; Save adds the filter +//! or replaces the one being edited (`svgFilter.define`). Delete removes the selected filter +//! (`svgFilter.delete`) and Import… adds the filters of an SVG file (`svgFilter.import`). Those +//! act at once. Effect › SVG Filters lists the same filters below its first items. +//! +//! Fields: `selected` (a filter's name), `code` (the code being edited), `editing` (`""`, `"new"` +//! or the name of the filter being edited), and with an applied effect `__index`, `__item` and +//! `missing` (the name of a deleted filter the effect names). + +use serde_json::{Value, json}; +use vectorcraft_engine::doc::SvgFilter; + +use super::DialogSpec; +use crate::picks::PickRequest; +use crate::state::Dialog; +use crate::{FilePick, VectorcraftApp, widgets}; + +/// The dialog kind of Apply SVG Filter. +pub const KIND: &str = "svgFilter"; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Apply SVG Filter…").trim_end_matches('…').to_string(), + body, + confirm, + ok_enabled: Some(|app, d| d.str("editing").is_empty() && filters(app).iter().any(|f| f.name == d.str("selected"))), + min_width: 440.0, + ..DialogSpec::FORM +}; + +/// The active document's SVG filters. +fn filters(app: &VectorcraftApp) -> Vec { + app.session.active().map(|st| st.doc.svg_filters.clone()).unwrap_or_default() +} + +/// `ui.svgFilterDialog {index?, item?}`: open Apply SVG Filter for the active document; with +/// `index`, for the applied SVG filter at `index` of `item` (null: the object's effects): the +/// document's filter of its name selected; when the document has none of its name, the name shown +/// above the list if the filter was deleted, or else its code open as a new filter. +pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { + if app.session.active().is_none() { + return Err("no document open".into()); + } + let mut fields = json!({ "selected": "", "code": "", "editing": "" }); + if let Some(k) = p.get("index").and_then(Value::as_u64) { + let mut q = json!({}); + if let Some(item) = p.get("item") { + q["item"] = item.clone(); + } + let applied = app.session.appearance_effect_target(&q).map(|(_, fx)| fx.to_vec()).unwrap_or_default(); + let e = applied.get(k as usize).filter(|e| e.id == vectorcraft_effects::SVG_FILTER).ok_or_else(|| format!("no SVG filter at index {k}"))?; + let name = e.params.get("name").and_then(Value::as_str).unwrap_or_default().to_string(); + let xml = e.params.get("xml").and_then(Value::as_str).unwrap_or_default().to_string(); + let held = filters(app).into_iter().find(|f| f.name == name); + match held { + // The document's filter it names: selected. + Some(_) => fields["selected"] = json!(name), + // Deleted from the document: shown above the list, not selectable; OK waits for a choice. + None if xml.is_empty() => fields["missing"] = json!(name), + // A filter the document doesn't hold (from an opened file): its code, to add it. + None => { + fields["code"] = json!(xml); + fields["editing"] = json!("new"); + } + } + fields["__index"] = json!(k); + // As given: `null` is the object's own effects, and none is the active item's. + if let Some(item) = p.get("item") { + fields["__item"] = item.clone(); + } + } + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +/// OK: the selected filter to the selection, or in the applied effect's place. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let name = d.str("selected"); + let f = filters(app).into_iter().find(|f| f.name == name).ok_or("select an SVG filter")?; + let r = match d.fields.get("__index").and_then(Value::as_u64) { + Some(k) => { + let mut p = json!({ "index": k, "params": { "name": f.name, "xml": f.xml } }); + if let Some(item) = d.fields.get("__item") { + p["item"] = item.clone(); + } + app.run("effect.setParams", p)? + } + None => app.run("svgFilter.apply", json!({ "name": f.name }))?, + }; + app.ui.dialog = None; + Ok(r) +} + +/// The active document's filter names, mirrored for the (static) menu tree ([`sync`]). +fn names_store() -> &'static std::sync::RwLock> { + static NAMES: std::sync::OnceLock>> = std::sync::OnceLock::new(); + NAMES.get_or_init(Default::default) +} + +/// Mirror the active document's SVG filter names for the menu tree (cheap when unchanged). +pub fn sync(app: &VectorcraftApp) { + let names: Vec = app.session.active().map(|st| st.doc.svg_filters.iter().map(|f| f.name.clone()).collect()).unwrap_or_default(); + let same = names_store().read().map(|v| v.len() == names.len() && v.iter().zip(&names).all(|(a, b)| *a == b)).unwrap_or(true); + if !same { + if let Ok(mut v) = names_store().write() { + *v = names.iter().map(|n| crate::shortcut_editor::intern(n)).collect(); + } + crate::shortcut_editor::GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } +} + +/// Effect › SVG Filters: Apply SVG Filter…, Import SVG Filter… and then the active document's +/// filters, each applying itself to the selection. +pub fn menu_items() -> Vec { + items_for(names_store().read().map(|v| v.clone()).unwrap_or_default()) +} + +/// [`menu_items`] for filters named `names`. +fn items_for(names: Vec<&'static str>) -> Vec { + use crate::menus::Item; + let mut v = + vec![Item::Cmd("Apply SVG Filter…", "ui.svgFilterDialog", Value::Null), Item::Cmd("Import SVG Filter…", "ui.importSvgFilter", Value::Null)]; + if !names.is_empty() { + v.push(Item::Sep); + v.extend(names.into_iter().map(|n| Item::Cmd(n, "svgFilter.apply", json!({ "name": n })))); + } + v +} + +/// `ui.importSvgFilter {}`: add the filters of an SVG file the user picks to the active document. +pub fn import_file(app: &mut VectorcraftApp) -> Result { + let path = crate::picks::open(app, &pick()).ok_or("cancelled")?; + let r = app.run("svgFilter.import", json!({ "path": path }))?; + let added = r["added"].as_array().map_or(0, Vec::len); + app.status(crate::i18n::msg_n(added as u64, "Imported {n} SVG filter", "Imported {n} SVG filters", &[])); + Ok(r) +} + +/// The extensions of the SVG files Import… reads. +const SVG_EXTS: &[&str] = &["svg"]; + +/// The open dialog for an SVG file. +pub(crate) fn pick() -> FilePick { + FilePick { filters: vec![FilePick::filter("SVG", SVG_EXTS)], ..Default::default() } +} + +/// Import…: the filters of the SVG file picked in the open dialog, added to the document, the first +/// one added selected. +fn import(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> { + let Some(path) = crate::picks::open(app, &pick()) else { return Ok(()) }; + let r = app.run("svgFilter.import", json!({ "path": path }))?; + if let Some(first) = r["added"].get(0).and_then(Value::as_str) { + d.fields.insert("selected".into(), json!(first)); + } + Ok(()) +} + +/// What a button asked for, done once the dialog is drawn. +enum Action { + Select(String), + New, + /// Open this filter's code (a double-clicked row, or the selected one). + Edit(String), + Delete, + Import, + Save, + Discard, +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let list = filters(app); + let selected = d.str("selected"); + let editing = d.str("editing"); + let mut act = None; + widgets::list_box(ui, |ui| { + egui::ScrollArea::vertical().id_salt("svg-filters").min_scrolled_height(160.0).max_height(160.0).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.set_min_height(160.0); + // Filter names are data: shown as they are. A deleted filter the effect still names + // comes first, dimmed and not selectable. + let missing = d.str("missing"); + if !missing.is_empty() { + ui.add_enabled_ui(false, |ui| ui.selectable_label(false, missing.as_str())); + } + for f in &list { + let r = ui.selectable_label(f.name == selected, f.name.as_str()); + if r.double_clicked() && editing.is_empty() { + act = Some(Action::Edit(f.name.clone())); + } else if r.clicked() { + act = Some(Action::Select(f.name.clone())); + } + } + }); + }); + ui.add_space(6.0); + let has = list.iter().any(|f| f.name == selected); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let idle = editing.is_empty(); + if ui.add_enabled_ui(idle && list.len() < SvgFilter::MAX, |ui| widgets::flat_button(ui, tl!("New"), 64.0)).inner.clicked() { + act = Some(Action::New); + } + if ui.add_enabled_ui(idle && has, |ui| widgets::flat_button(ui, tl!("Edit"), 64.0)).inner.clicked() { + act = Some(Action::Edit(selected.clone())); + } + if ui.add_enabled_ui(idle && has, |ui| widgets::flat_button(ui, tl!("Delete"), 64.0)).inner.clicked() { + act = Some(Action::Delete); + } + if crate::picks::can(app, &PickRequest::Open(pick())) + && ui.add_enabled_ui(idle, |ui| widgets::flat_button(ui, tl!("Import…"), 64.0)).inner.clicked() + { + act = Some(Action::Import); + } + }); + if !editing.is_empty() { + ui.add_space(8.0); + let mut code = d.str("code"); + // The filter's code is data: edited as it is written. + let r = ui.add(egui::TextEdit::multiline(&mut code).font(egui::TextStyle::Monospace).desired_width(f32::INFINITY).desired_rows(10)); + if r.changed() { + d.fields.insert("code".into(), json!(code)); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if ui.add_enabled_ui(!code.trim().is_empty(), |ui| widgets::flat_button(ui, tl!("Save"), 64.0)).inner.clicked() { + act = Some(Action::Save); + } + if widgets::flat_button(ui, tl!("Cancel"), 64.0).clicked() { + act = Some(Action::Discard); + } + }); + } + let r = match act { + None => Ok(()), + Some(Action::Select(name)) => { + d.fields.insert("selected".into(), json!(name)); + Ok(()) + } + Some(Action::New) => { + d.fields.insert("editing".into(), json!("new")); + d.fields.insert("code".into(), json!("")); + Ok(()) + } + Some(Action::Edit(name)) => { + if let Some(f) = list.iter().find(|f| f.name == name) { + d.fields.insert("selected".into(), json!(f.name)); + d.fields.insert("editing".into(), json!(f.name)); + d.fields.insert("code".into(), json!(f.xml)); + } + Ok(()) + } + Some(Action::Delete) => app.run("svgFilter.delete", json!({ "name": selected })).map(|_| { + d.fields.insert("selected".into(), json!("")); + }), + // The open dialog, shown off the UI thread, fills the dialog as it is then. + Some(Action::Import) => crate::picks::in_dialog(app, d, import), + Some(Action::Save) => { + let mut p = json!({ "xml": d.str("code") }); + if editing != "new" { + p["replace"] = json!(editing); + } + app.run("svgFilter.define", p).map(|r| { + d.fields.insert("selected".into(), r["name"].clone()); + d.fields.insert("editing".into(), json!("")); + d.fields.insert("code".into(), json!("")); + }) + } + Some(Action::Discard) => { + d.fields.insert("editing".into(), json!("")); + d.fields.insert("code".into(), json!("")); + Ok(()) + } + }; + if let Err(e) = r { + app.status(e); + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + const SHIFT: &str = r#""#; + + fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + app + } + + /// One headless frame of the dialog's body: the texts drawn. + fn frame(app: &mut VectorcraftApp, d: &mut Dialog) -> Vec { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + body(app, ui, d); + }); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); + v + } + + #[test] + fn lists_the_documents_filters_and_ok_applies_the_selected_one() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + assert!(open(&mut app, &json!({})).is_err(), "no document"); + let mut app = self::app(); + app.session.execute("svgFilter.define", &json!({"xml": SHIFT})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + crate::menus::invoke(&mut app, "ui.svgFilterDialog", json!({})); + let mut d = app.ui.dialog.clone().unwrap(); + assert_eq!(d.kind, KIND); + assert!(frame(&mut app, &mut d).iter().any(|t| t == "Shift"), "the filter's name, as it is"); + assert!(!(SPEC.ok_enabled.unwrap())(&app, &d), "nothing selected"); + d.fields.insert("selected".into(), json!("Shift")); + assert!((SPEC.ok_enabled.unwrap())(&app, &d)); + confirm(&mut app, &d).unwrap(); + assert!(app.ui.dialog.is_none()); + let fx = &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects; + assert_eq!((fx.len(), fx[0].params["name"].as_str()), (1, Some("Shift"))); + } + + #[test] + fn opened_for_an_applied_filter_ok_replaces_it() { + let mut app = app(); + app.session.execute("svgFilter.define", &json!({"xml": SHIFT})).unwrap(); + app.session.execute("svgFilter.define", &json!({"xml": ""})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("svgFilter.apply", &json!({"name": "Shift"})).unwrap(); + // With a fill row active, the object's own effect is still the one edited. + app.session.execute("appearance.setActiveItem", &json!({"index": 0})).unwrap(); + // The Appearance panel opens the effect's dialog: this one. + app.run("effect.dialog", json!({"effect": "svg.filter", "index": 0, "item": null})).unwrap(); + let mut d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.kind.as_str(), d.str("selected").as_str()), (KIND, "Shift")); + d.fields.insert("selected".into(), json!("Other")); + confirm(&mut app, &d).unwrap(); + let fx = &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects; + assert_eq!((fx.len(), fx[0].params["name"].as_str()), (1, Some("Other")), "in its place, not added"); + // Edited since it was applied: the effect follows the edit, and the filter stays selected. + app.session.execute("svgFilter.define", &json!({"xml": "", "replace": "Other"})).unwrap(); + app.run("effect.dialog", json!({"effect": "svg.filter", "index": 0, "item": null})).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.str("selected").as_str(), d.str("editing").as_str()), ("Other", "")); + // Deleted: its name shows above the list, nothing is selected, and OK waits for a choice. + app.session.execute("svgFilter.delete", &json!({"name": "Other"})).unwrap(); + app.run("effect.dialog", json!({"effect": "svg.filter", "index": 0, "item": null})).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.str("missing").as_str(), d.str("selected").as_str(), d.str("editing").as_str()), ("Other", "", "")); + assert!(!(SPEC.ok_enabled.unwrap())(&app, &d)); + // An applied filter the document never held (from an opened file) opens as code to save. + let e = vectorcraft_doc::Effect { + id: "svg.filter".into(), + params: json!({"name": "Kept", "xml": ""}), + visible: true, + }; + app.session + .edit("Plant", |d, _| { + d.node_mut(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects = vec![e]; + Ok(()) + }) + .unwrap(); + app.run("effect.dialog", json!({"effect": "svg.filter", "index": 0, "item": null})).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!(d.str("editing"), "new"); + assert!(d.str("code").contains("id=\"Kept\"")); + } + + /// Edit, started from one object's effect in the Appearance panel, changes every object that uses + /// the filter; a deleted filter's name is drawn above the list. + #[test] + fn an_edit_from_one_objects_effect_changes_every_user() { + let mut app = app(); + app.session.execute("svgFilter.define", &json!({"xml": SHIFT})).unwrap(); + let rect = |app: &mut VectorcraftApp, x: f64| { + app.session.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap() + }; + let (a, b) = (rect(&mut app, 10.0), rect(&mut app, 50.0)); + app.session.execute("svgFilter.apply", &json!({"name": "Shift", "ids": [a, b]})).unwrap(); + app.session.execute("select.set", &json!({"ids": [a]})).unwrap(); + app.run("effect.dialog", json!({"effect": "svg.filter", "index": 0, "item": null})).unwrap(); + let mut d = app.ui.dialog.clone().unwrap(); + // Edit opens the selected filter's code; Save replaces it, as its button does. + d.fields.insert("editing".into(), json!(d.str("selected"))); + let edited = r#""#; + app.run("svgFilter.define", json!({"xml": edited, "replace": d.str("editing")})).unwrap(); + let xml = |app: &VectorcraftApp, id: u64| { + app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params["xml"].clone() + }; + assert_eq!((xml(&app, a), xml(&app, b)), (json!(edited), json!(edited))); + app.session.execute("svgFilter.delete", &json!({"name": "Shift"})).unwrap(); + app.run("effect.dialog", json!({"effect": "svg.filter", "index": 0, "item": null})).unwrap(); + let mut d = app.ui.dialog.clone().unwrap(); + assert!(frame(&mut app, &mut d).iter().any(|t| t == "Shift"), "the deleted filter's name"); + } + + #[test] + fn the_submenu_lists_the_documents_filters_below_its_commands() { + use crate::menus::Item; + let ids = + |items: &[Item]| items.iter().map(|i| if let Item::Cmd(l, c, p) = i { format!("{l} {c} {p}") } else { "—".into() }).collect::>(); + assert_eq!(ids(&items_for(vec![])), ["Apply SVG Filter… ui.svgFilterDialog null", "Import SVG Filter… ui.importSvgFilter null"]); + assert_eq!( + ids(&items_for(vec!["Shift", "A (1)"]))[2..], + ["—", "Shift svgFilter.apply {\"name\":\"Shift\"}", "A (1) svgFilter.apply {\"name\":\"A (1)\"}"] + ); + let mut app = app(); + assert!(!crate::menus::enabled(&app, "svgFilter.apply"), "nothing selected"); + app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + assert!(crate::menus::enabled(&app, "svgFilter.apply")); + assert_eq!(crate::menus::display_label(&app, "svgFilter.apply", "Shift"), "Shift", "a filter's name as it is"); + } + + #[test] + fn new_saves_the_code_and_selects_the_filter() { + let mut app = app(); + open(&mut app, &json!({})).unwrap(); + let mut d = app.ui.dialog.clone().unwrap(); + d.fields.insert("editing".into(), json!("new")); + d.fields.insert("code".into(), json!(SHIFT)); + assert!(!(SPEC.ok_enabled.unwrap())(&app, &d), "not while the code is open"); + // Save, as its button does. + let r = app.run("svgFilter.define", json!({"xml": d.str("code")})).unwrap(); + assert_eq!(r["name"], "Shift"); + assert_eq!(app.session.active().unwrap().doc.svg_filters.len(), 1); + } + + #[test] + fn import_adds_the_picked_files_filters() { + let mut app = app(); + let path = std::env::temp_dir().join(format!("vc-ui-svgfilters-{}.svg", std::process::id())); + std::fs::write(&path, format!("{SHIFT}")).unwrap(); + let picked = path.to_string_lossy().into_owned(); + app.services.pick_open = Some(Box::new(move |_: &FilePick| Some(picked.clone()))); + let mut d = Dialog::new(KIND, json!({ "selected": "", "code": "", "editing": "" })); + import(&mut app, &mut d).unwrap(); + std::fs::remove_file(&path).ok(); + assert_eq!(d.str("selected"), "Shift"); + assert_eq!(app.session.active().unwrap().doc.svg_filters[0].name, "Shift"); + } +} diff --git a/crates/ui-egui/src/dialogs/svg_options.rs b/crates/ui-egui/src/dialogs/svg_options.rs index 14dc2d5a4..28939b068 100644 --- a/crates/ui-egui/src/dialogs/svg_options.rs +++ b/crates/ui-egui/src/dialogs/svg_options.rs @@ -112,7 +112,8 @@ pub fn open(app: &mut VectorcraftApp, mode: Mode, path: Option<&str>) { /// only when saving). fn options(d: &Dialog) -> Value { let mode = Mode::of(d); - let mut o: Map = d.fields.iter().filter(|(k, _)| !UI_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect(); + let mut o: Map = + d.fields.iter().filter(|(k, _)| !UI_KEYS.contains(&k.as_str()) && !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect(); if mode == Mode::Export { o.remove("preserveEditing"); if d.bool("useArtboards") { @@ -379,6 +380,19 @@ mod tests { assert_eq!(app.ui.dialog.as_ref().unwrap().f64("decimals", 0.0), 1.0); } + /// A dialog a save panel opened carries the dialog layer's own `__` fields; the save is given + /// only the SVG options, so it is not refused for an unknown key. + #[test] + fn save_as_svg_from_a_save_panel_passes_only_the_options() { + let (mut app, written) = app("/tmp/panel.svg"); + app.run("file.saveAs", Value::Null).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert(crate::dialogs::AFTER_PANEL.into(), json!(true)); + assert!(options(app.ui.dialog.as_ref().unwrap()).get(crate::dialogs::AFTER_PANEL).is_none()); + let d = app.ui.dialog.take().unwrap(); + confirm(&mut app, &d).unwrap_or_else(|e| panic!("{e}")); + assert_eq!(written.borrow()[0].0, "/tmp/panel.svg"); + } + #[test] fn all_artboards_export_one_file_each() { let (mut app, written) = app("/tmp/set.svg"); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 51959c320..7783e205f 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -466,12 +466,13 @@ fn dialogs_do_not_stretch_to_the_screen() { #[test] fn workspace_and_toolbar_dialogs_end_at_their_buttons() { // New Workspace, Manage Workspaces, New Toolbar and Manage Toolbars draw their own windows: each - // ends just below its OK button, with no empty band above or below the buttons. - for (kind, fields, window) in [ - ("newWorkspace", json!({"name": "Mine"}), "dialog-workspace"), - ("manageWorkspaces", json!({"selected": "", "name": ""}), "dialog-workspace"), - (crate::custom_toolbars::KIND, json!({"name": "Toolbar 1"}), "dialog-new-toolbar"), - (crate::custom_toolbars::MANAGE, json!({"selected": "", "name": ""}), "dialog-manage-toolbars"), + // ends just below its OK button, with no empty band above or below the buttons, and Cancel (where + // the dialog has one) sits on OK's row, to its left. + for (kind, fields, window, has_cancel) in [ + ("newWorkspace", json!({"name": "Mine"}), "dialog-workspace", true), + ("manageWorkspaces", json!({"selected": "", "name": ""}), "dialog-workspace", false), + (crate::custom_toolbars::KIND, json!({"name": "Toolbar 1"}), "dialog-new-toolbar", true), + (crate::custom_toolbars::MANAGE, json!({"selected": "", "name": ""}), "dialog-manage-toolbars", false), ] { let mut app = app(); open(&mut app, kind, fields); @@ -479,20 +480,46 @@ fn workspace_and_toolbar_dialogs_end_at_their_buttons() { theme::install_fonts(&ctx); theme::apply(&ctx, Default::default()); let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0)); - let mut ok = None; + let mut shapes = Vec::new(); for _ in 0..4 { let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; let mut out = ctx.run_ui(input, |ui| show(&mut app, ui.ctx())); out.textures_delta.clear(); - ok = out.shapes.iter().find_map(|c| match &c.shape { - egui::Shape::Text(t) if t.galley.text() == "OK" => Some(t.visual_bounding_rect()), - _ => None, - }); + shapes = out.shapes; } - let ok = ok.unwrap_or_else(|| panic!("{kind} draws no OK button")); let outer = ctx.memory(|m| m.area_rect(egui::Id::unique(window))).unwrap(); + let texts = |label: &str| -> Vec { + shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text() == label && outer.intersects(t.visual_bounding_rect()) => Some(t.visual_bounding_rect()), + _ => None, + }) + .collect() + }; + let oks = texts("OK"); + assert_eq!(oks.len(), 1, "{kind} draws {} OK buttons in its window", oks.len()); + let ok = oks[0]; let below = outer.bottom() - ok.bottom(); - assert!(below < 40.0, "{kind}: {below} pt between OK and the bottom of its {} pt window", outer.height()); + assert!((0.0..40.0).contains(&below), "{kind}: {below} pt between OK and the bottom of its {} pt window", outer.height()); + // The nearest thing drawn above the button row: the last field, its label or its list. + let above = shapes + .iter() + .map(|c| c.shape.visual_bounding_rect()) + .filter(|r| r.is_finite() && outer.intersects(*r) && r.bottom() <= ok.top()) + .map(|r| r.bottom()) + .fold(outer.top(), f32::max); + let gap = ok.top() - above; + assert!(gap < 40.0, "{kind}: {gap} pt between OK and what is drawn above it in its {} pt window", outer.height()); + let cancels = texts("Cancel"); + if has_cancel { + assert_eq!(cancels.len(), 1, "{kind} draws {} Cancel buttons in its window", cancels.len()); + let cancel = cancels[0]; + assert!((cancel.center().y - ok.center().y).abs() < 1.0, "{kind}: Cancel at y {} and OK at y {}", cancel.center().y, ok.center().y); + assert!(cancel.right() < ok.left(), "{kind}: Cancel ends at x {} but OK starts at x {}", cancel.right(), ok.left()); + } else { + assert!(cancels.is_empty(), "{kind} draws a Cancel button"); + } } } @@ -627,9 +654,12 @@ impl PluginIntField { author: String::new(), api: 1, permissions: None, + inputs: Vec::new(), params: vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 }.into())], extensions: Vec::new(), budget: Default::default(), + paint_output: false, + commands: Vec::new(), }; 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/dialogs/tests_accessibility.rs b/crates/ui-egui/src/dialogs/tests_accessibility.rs index ac469f6b7..8525fd8bb 100644 --- a/crates/ui-egui/src/dialogs/tests_accessibility.rs +++ b/crates/ui-egui/src/dialogs/tests_accessibility.rs @@ -81,6 +81,33 @@ fn document_setup_names_each_bleed_side() { } } +/// New Document's Preset Details column names each field by the caption above it. +#[test] +fn new_document_names_its_fields_by_their_captions() { + let mut app = app(); + app.run("file.newDialog", json!({})).unwrap(); + app.ui.dialog.as_mut().expect("New Document is open").fields.insert("__saving".into(), json!(true)); + let h = strict(app); + for name in ["Document Name", "Preset Name", "Width", "Height", "Artboards"] { + let _ = h.get_by_role_and_label(Role::TextInput, name); + } + for name in ["Units", "Background Contents"] { + let _ = h.get_by_role_and_label(Role::ComboBox, name); + } +} + +/// New Document's More Settings, with Save Preset open, leaves no field unnamed. +#[test] +fn new_document_more_settings_names_its_fields() { + let mut app = app(); + app.run("file.newDialog", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().expect("New Document is open"); + d.kind = super::new_document::MORE.into(); + d.fields.insert("__saving".into(), json!(true)); + let h = strict(app); + let _ = h.get_by_role_and_label(Role::TextInput, "Preset Name"); +} + /// Split Into Grid keeps translated visible labels while naming row and column gutter and total /// inputs by axis and context. #[test] diff --git a/crates/ui-egui/src/dialogs/tests_dxf.rs b/crates/ui-egui/src/dialogs/tests_dxf.rs index 252c67f7e..22429b164 100644 --- a/crates/ui-egui/src/dialogs/tests_dxf.rs +++ b/crates/ui-egui/src/dialogs/tests_dxf.rs @@ -40,6 +40,66 @@ fn export_as_dxf_shows_dxf_options_then_writes_and_remembers_them() { assert!(app.ui.dxf_options.get("useArtboards").is_none()); } +#[test] +fn the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom() { + let (mut app, written) = app(1); + app.run("ui.dxfOptionsDialog", json!({"path": "/out/a.dxf"})).unwrap(); + frame(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("dxfPreset"), "custom"); + set(&mut app, "dxfPreset", json!("cnc")); + frame(&mut app); + let d = app.ui.dialog.as_ref().unwrap(); + let fields = |d: &crate::state::Dialog| { + ["dxfPreset", "version", "unit", "scale", "colors", "preserve", "alterPaths", "outlineText"].map(|k| d.str(k)).join(" ") + }; + assert_eq!(fields(d), "cnc 2000 Millimeters 1.0 256 editability false true"); + // Any change to a field the preset sets shows Custom, and keeps the other fields. + set(&mut app, "colors", json!("true")); + frame(&mut app); + assert_eq!(fields(app.ui.dialog.as_ref().unwrap()), "custom 2000 Millimeters 1.0 true editability false true"); + set(&mut app, "colors", json!("256")); + frame(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("dxfPreset"), "cnc", "matching every field again"); + confirm(&mut app).unwrap(); + let dxf = text(&written.borrow()[0].1); + let lines: Vec<&str> = dxf.lines().collect(); + let header = |var: &str| lines.iter().position(|l| *l == var).and_then(|i| lines.get(i + 2)).map(|v| v.trim()); + assert_eq!((header("$ACADVER"), header("$INSUNITS")), (Some("AC1015"), Some("4"))); + // The next DXF Options start from the preset; Custom puts back the defaults. + crate::menus::invoke(&mut app, "ui.dxfOptionsDialog", json!({})); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("dxfPreset"), "cnc"); + set(&mut app, "dxfPreset", json!("custom")); + frame(&mut app); + assert_eq!(fields(app.ui.dialog.as_ref().unwrap()), "custom 2018 Millimeters 1.0 true appearance false false"); + // A preset the command gives sets the fields it does not give. + app.ui.dialog = None; + app.run("ui.dxfOptionsDialog", json!({"dxfPreset": "cnc", "unit": "Inches"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(fields(d), "custom 2000 Inches 1.0 256 editability false true"); + // A preset set and confirmed without drawing (the control channel) sets its fields too. + set(&mut app, "dxfPreset", json!("cnc")); + confirm(&mut app).unwrap(); + assert_eq!((app.ui.dxf_options["dxfPreset"].as_str(), app.ui.dxf_options["unit"].as_str()), (Some("cnc"), Some("Millimeters"))); + // `ui.dialog.set` sets the preset's fields at once, so a field set after it keeps its value. + app.run("ui.dxfOptionsDialog", json!({"dxfPreset": "custom"})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + set_field(d, "dxfPreset".into(), json!("cnc")); + assert_eq!(fields(d), "cnc 2000 Millimeters 1.0 256 editability false true"); + set_field(d, "unit".into(), json!("Inches")); + assert_eq!(d.str("dxfPreset"), "custom"); + // Setting the preset again sets its fields again. + set_field(d, "dxfPreset".into(), json!("cnc")); + assert_eq!(d.str("unit"), "Millimeters"); + set_field(d, "unit".into(), json!("Inches")); + // Setting Custom while Custom is shown still puts back the defaults. + let mut reset = d.clone(); + set_field(&mut reset, "dxfPreset".into(), json!("custom")); + assert_eq!(fields(&reset), "custom 2018 Millimeters 1.0 true appearance false false"); + confirm(&mut app).unwrap(); + let remembered = ["dxfPreset", "version", "unit"].map(|k| app.ui.dxf_options[k].as_str()); + assert_eq!(remembered, [Some("custom"), Some("2000"), Some("Inches")]); +} + #[test] fn bad_dxf_options_keep_the_dialog_open() { let (mut app, written) = app(1); @@ -47,6 +107,12 @@ fn bad_dxf_options_keep_the_dialog_open() { set(&mut app, "scale", json!(-1)); assert!(confirm(&mut app).is_err()); assert_eq!(kind(&app), Some("dxfOptions")); + set(&mut app, "scale", json!(1)); + set(&mut app, "dxfPreset", json!("laser")); + frame(&mut app); + let e = confirm(&mut app).unwrap_err(); + assert!(e.contains("custom or cnc"), "{e}"); + set(&mut app, "dxfPreset", json!("custom")); set(&mut app, "scale", json!(10)); // Export Selected Art Only with nothing selected. app.run("select.none", json!({})).unwrap(); diff --git a/crates/ui-egui/src/dialogs/tests_import_pdf.rs b/crates/ui-egui/src/dialogs/tests_import_pdf.rs index 883014739..0a1f5b5f7 100644 --- a/crates/ui-egui/src/dialogs/tests_import_pdf.rs +++ b/crates/ui-egui/src/dialogs/tests_import_pdf.rs @@ -3,7 +3,8 @@ use serde_json::json; use vectorcraft_doc::NodeKind; use vectorcraft_engine::Session; -use vectorcraft_testkit::pdf::{PdfPage, pdf}; +use vectorcraft_testkit::ai; +use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf, pdf_with}; use super::*; use crate::io; @@ -197,3 +198,72 @@ fn placing_a_saved_ai_still_asks_for_the_page() { assert!(import_pdf::offer(&mut app, "two.ai", &bytes, None, Some(&json!({})))); assert_eq!(app.ui.dialog.as_ref().unwrap().str("mode"), "place"); } + +/// Two pages, 100 and 200 pt wide; with `editing_data` the first page has the keys of an editor's +/// copy of the art. +fn two_pages(editing_data: bool, password: Option<&str>) -> Vec { + let mut first = PdfPage::new(100.0, 100.0, "0 g 0 0 10 10 re f"); + if editing_data { + first.entries = "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>".into(); + } + pdf(&[first, PdfPage::new(200.0, 100.0, "1 0 0 rg 0 0 30 30 re f")], password) +} + +/// Two 100 pt pages and, on the first, editing data with two artboards to match and a layer of its +/// own, which draws the square the first page draws. +fn two_pages_with_a_layer_in_the_editing_data(layer: &str) -> Vec { + let point = |x: i32, y: i32, n: u8| format!("%_{x} {y} /RealPointRelToROrigin\n%_ (PositionPoint{n}) ,\n"); + let board = |top: i32| format!("%_/Dictionary :\n{}{}%_; ,\n", point(0, top, 1), point(100, top - 100, 2)); + let boards = format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{}{}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n", board(100), board(-50)); + let art = format!("0 0 0 1 k\n{}f\n", ai::rect(10.0, 10.0, 30.0, 30.0)); + let data = ai::editing_data(100.0, 100.0, &format!("{boards}{}", ai::layer(layer, &art))); + let mut first = PdfPage::new(100.0, 100.0, "0 0 0 1 k 10 10 20 20 re f"); + first.entries = format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {} 0 R /NumBlock 1 >> >> >>", first_extra(2)); + let stream = format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len()); + pdf_with(&[first, PdfPage::new(100.0, 100.0, "")], &[&stream], None) +} + +/// #6135: the editing data of a `.ai` holds every artboard, so opening one asks nothing, opens +/// them all and reads the art from that data: the layer is in nothing else. +#[test] +fn opening_an_ai_with_editing_data_reads_it_without_asking() { + let mut app = app(); + let bytes = two_pages_with_a_layer_in_the_editing_data("Only in the editing data"); + io::open_bytes(&mut app, "two.ai", &bytes, Some("/tmp/two.ai".into())).unwrap(); + assert!(app.ui.dialog.is_none(), "no Import PDF dialog"); + assert_eq!(app.session.documents().len(), 2); + assert_eq!(artboard_widths(&app), [100.0, 100.0]); + let layers = &app.session.active().unwrap().doc.layers; + assert!(layers.iter().any(|l| l.name.as_deref() == Some("Only in the editing data")), "read from the editing data"); + assert!(!import_pdf::offer(&mut app, "two.ait", &bytes, None, None), "a template too"); + assert!(app.ui.dialog.is_none()); +} + +/// A `.ai` whose editing data can't be read asks nothing either, and every page opens from the PDF +/// part. Asking at that point is #6134. +#[test] +fn opening_an_ai_with_unreadable_editing_data_opens_every_page_without_asking() { + let mut app = app(); + io::open_bytes(&mut app, "two.ai", &two_pages(true, None), Some("/tmp/two.ai".into())).unwrap(); + assert!(app.ui.dialog.is_none(), "no Import PDF dialog"); + assert_eq!(artboard_widths(&app), [100.0, 200.0]); +} + +/// #6135: the dialog stays for what still has something to ask: a PDF, even one with editing data +/// keys, a `.ai` that is only a PDF, a password, and Place. +#[test] +fn the_import_dialog_stays_where_there_is_something_to_ask() { + for (name, bytes) in [("two.pdf", two_pages(true, None)), ("two.ai", two_pages(false, None))] { + let mut app = app(); + assert!(import_pdf::offer(&mut app, name, &bytes, None, None), "{name}"); + assert_eq!((app.ui.dialog.as_ref().unwrap().str("mode"), field(&app, "pageCount")), ("open".into(), json!(2)), "{name}"); + } + + let mut app = app(); + assert!(import_pdf::offer(&mut app, "locked.ai", &two_pages(true, Some("pw")), None, None)); + assert_eq!(field(&app, "__locked"), true); + + let mut app = self::app(); + assert!(import_pdf::offer(&mut app, "two.ai", &two_pages(true, None), None, Some(&json!({})))); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("mode"), "place"); +} diff --git a/crates/ui-egui/src/dialogs/tests_plugin_fields.rs b/crates/ui-egui/src/dialogs/tests_plugin_fields.rs index 8a66bc8a9..f5f4e719a 100644 --- a/crates/ui-egui/src/dialogs/tests_plugin_fields.rs +++ b/crates/ui-egui/src/dialogs/tests_plugin_fields.rs @@ -13,6 +13,13 @@ use vectorcraft_plugins::wat as tpl; use super::*; +/// The install approval has no OK in the shared footer, so Enter presses OK only when OK has the +/// keyboard focus. +#[test] +fn enter_does_not_answer_the_install_approval() { + assert!(plugin::INSTALL_SPEC.ok.is_none() && plugin::INSTALL_SPEC.body_with_actions.is_some()); +} + /// A filter whose parameters use every presentation key. fn manifest(id: &str) -> String { format!( 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..7dd035c1e --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_plugin_import.rs @@ -0,0 +1,292 @@ +//! 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::{self as tpl, Given}; + +use super::tests_export::{frame, kind, set}; +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"); + 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") }; + 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(); + 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); +} + +/// 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.importparams", 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.importparams", "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.importparams".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)); + + // Only a file from the picker goes off the UI thread after the dialog: a caller naming `path` + // gets the document. + app.run("file.openWith", json!({"importer": "plugin.test.ui.importparams", "path": file})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__open"]["picked"], false); + app.ui.dialog = None; + let picked = file.clone(); + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))); + app.run("file.openWith", json!({"importer": "plugin.test.ui.importparams"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__open"]["picked"], true); + app.ui.dialog = None; + + let r = app.run("file.openWith", json!({"importer": "plugin.test.ui.importparams", "path": file, "params": {"density": 7}})).unwrap(); + assert_eq!(r["format"], "plugin.test.ui.importparams"); + 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.importparams", "params": {"density": 7}})); + assert_eq!(r["result"]["format"], "plugin.test.ui.importparams", "{r}"); + assert_eq!(app.session.documents().len(), 3); + let r = control(&mut app, json!({"path": file, "importer": "plugin.test.ui.importparams"})); + 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); + + // Without `importer`, `params` go to the importer File › Open picks for the file. + let other = dir.join("b.tup"); + std::fs::write(&other, "not json").unwrap(); + let other = other.to_string_lossy().into_owned(); + let r = control(&mut app, json!({"path": other, "params": {"density": 7}})); + assert_eq!(r["result"]["format"], "plugin.test.ui.importparams", "{r}"); + assert_eq!(app.session.documents().len(), 4); + app.run("plugin.remove", json!({"id": "test.ui.importparams"})).unwrap(); + 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(); +} + +/// Export As lists an installed exporter and not an installed importer, whose format can only be +/// read, and it does not preset the importer's format. +#[test] +fn export_as_lists_exporters_and_not_importers() { + let mut app = app(); + install(&mut app, &tpl::module(&tpl::importer("test.ui.readonly", r#"["tur"]"#, "{}"), tpl::ECHO, "", 1)); + install(&mut app, &tpl::module(&tpl::exporter("test.ui.writes", r#"["tuw"]"#, "{}"), tpl::ECHO, "", 1)); + let listed: Vec<&str> = export_as::formats().iter().map(|f| f.id).collect(); + assert!(listed.contains(&"plugin.test.ui.writes"), "{listed:?}"); + assert!(!listed.contains(&"plugin.test.ui.readonly"), "{listed:?}"); + app.run("file.exportAs", json!({"format": "plugin.test.ui.readonly"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("format"), "png"); + app.run("plugin.remove", json!({"id": "test.ui.readonly"})).unwrap(); + app.run("plugin.remove", json!({"id": "test.ui.writes"})).unwrap(); +} + +/// A file only an importer reads opens wherever it is dropped, also when an exporter lists its +/// extension; a file File › Place reads is still placed on the canvas. +#[test] +fn a_dropped_file_only_an_importer_reads_opens() { + use crate::place::DropTarget; + let mut app = app(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0))); + let on_canvas = Some(egui::pos2(400.0, 300.0)); + let placed = |app: &VectorcraftApp, name, pos| matches!(app.drop_target(name, pos, false), DropTarget::Place(_)); + assert!(placed(&app, "a.tud", on_canvas), "no importer lists the extension yet"); + + install(&mut app, &tpl::module(&tpl::importer("test.ui.dropped", r#"["tud","tus","png"]"#, "{}"), tpl::ECHO, "", 1)); + install(&mut app, &tpl::module(&tpl::exporter("test.ui.dropshare", r#"["tus"]"#, "{}"), tpl::ECHO, "", 1)); + for name in ["a.tud", "A.TUD", "a.tus"] { + assert_eq!(app.drop_target(name, on_canvas, false), DropTarget::Open, "{name} on the canvas"); + assert_eq!(app.drop_target(name, None, false), DropTarget::Open, "{name} without a position"); + } + assert!(placed(&app, "a.png", on_canvas), "File › Place reads the extension"); + assert!(placed(&app, "a.png", None)); + + app.run("plugin.remove", json!({"id": "test.ui.dropped"})).unwrap(); + app.run("plugin.remove", json!({"id": "test.ui.dropshare"})).unwrap(); +} + +/// The extensions File › Open's filters leave out of importers' (`fileio::opens_as_other`) are the +/// ones File › Open installs, imports, checks or loads as something other than a document. +#[test] +fn importer_filters_leave_out_what_file_open_opens_as_something_else() { + use vectorcraft_engine::cmd::{self, fileio}; + let lists: [&[&str]; 11] = [ + cmd::plugin::EXTS, + cmd::shortcutset::EXTS, + cmd::scripts::EXTS, + cmd::flatten::PRESET_EXTS, + cmd::pdfcmds::PRESET_EXTS, + cmd::printpresets::PRESET_EXTS, + cmd::perspgrid::PRESET_EXTS, + fileio::SCREEN_EXPORT_PRESET_EXTS, + cmd::compositefonts::EXTS, + cmd::library::LIBRARY_EXTS, + cmd::swatchlib::LIBRARY_EXTS, + ]; + for ext in lists.iter().flat_map(|l| l.iter().copied()).chain([vectorcraft_doc::style_libs::STYLES_EXT]) { + assert!(crate::io::opens_as(&format!("a.{ext}")).is_some(), "{ext}"); + assert!(fileio::opens_as_other(ext), "{ext}"); + } + for ext in ["svg", "tcl", "pdf"] { + assert!(crate::io::opens_as(&format!("a.{ext}")).is_none() && !fileio::opens_as_other(ext), "{ext}"); + } + // File › Open's dialog lists an importer's `.tuo` but not its `.ase` (a swatch library there). + let mut app = app(); + install(&mut app, &tpl::module(&tpl::importer("test.ui.claimed", r#"["tuo","ase"]"#, "{}"), 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() = Some(pick.clone()); + None + })); + let _ = app.run("file.open", json!({})); + let pick = asked.borrow_mut().take().expect("File › Open showed its dialog"); + let own = pick.filters.iter().find(|(label, _)| label == "Test test.ui.claimed").map(|(_, exts)| exts.clone()); + assert_eq!(own, Some(vec!["tuo".to_string()]), "{:?}", pick.filters); + assert!(!pick.filters[0].1.iter().any(|e| e == "ase"), "All readable files: {:?}", pick.filters[0]); + app.run("plugin.remove", json!({"id": "test.ui.claimed"})).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] +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/dialogs/tests_print.rs b/crates/ui-egui/src/dialogs/tests_print.rs index bf213c789..3bf847d0e 100644 --- a/crates/ui-egui/src/dialogs/tests_print.rs +++ b/crates/ui-egui/src/dialogs/tests_print.rs @@ -216,6 +216,24 @@ fn without_a_print_service_print_saves_a_pdf() { assert_eq!(app.run("print.printers", json!({})).unwrap(), json!({"printers": [], "service": false, "setup": false})); } +#[test] +fn copies_per_sheet_prints_a_grid_of_each_artboard() { + let (mut app, jobs) = app(); + app.run("file.print", json!({})).unwrap(); + set(&mut app, "copiesPerSheet", json!(true)); + set(&mut app, "gutter", json!(9)); + frame(&mut app); + let r = super::confirm(&mut app).unwrap(); + assert_eq!(r["pages"], 3, "a sheet per artboard"); + assert_eq!(pages(&jobs.borrow()[0].2), 3); + let saved = app.session.active().unwrap().doc.print_setup.clone().unwrap(); + assert_eq!((saved["copiesPerSheet"].clone(), saved["gutter"].clone()), (json!(true), json!(9.0))); + // Each 120 × 90 artboard fills landscape Letter 6 across and 6 down, 9 pt apart. + let pv = app.session.execute("print.preview", &json!({})).unwrap(); + assert_eq!(pv["sheets"][0]["copies"].as_array().map(Vec::len), Some(36)); + assert_eq!(pv["sheets"][0]["copies"][1], json!([142.5, 13.5, 262.5, 103.5])); +} + #[test] fn dragging_the_preview_moves_the_placement_on_the_paper() { let (mut app, _) = app(); @@ -225,6 +243,8 @@ fn dragging_the_preview_moves_the_placement_on_the_paper() { (json!({"autoRotate": false, "orientation": "portraitFlipped"}), (10.0, 5.0)), (json!({"output": {"emulsion": "down"}, "transverse": true}), (-4.0, 7.0)), (json!({"scaling": "custom", "scale": {"width": 50, "height": 200}}), (6.0, -8.0)), + // Copies per sheet: the drag moves the whole grid. + (json!({"copiesPerSheet": true, "gutter": 9}), (10.0, 5.0)), ] { let before = app.session.execute("print.preview", &json!({ "settings": settings })).unwrap(); let sheet = &before["sheets"][0]; diff --git a/crates/ui-egui/src/dialogs/tests_screen_assets.rs b/crates/ui-egui/src/dialogs/tests_screen_assets.rs index 32f585e81..df218bd0a 100644 --- a/crates/ui-egui/src/dialogs/tests_screen_assets.rs +++ b/crates/ui-egui/src/dialogs/tests_screen_assets.rs @@ -122,6 +122,28 @@ fn the_assets_tab_exports_the_checked_assets_into_the_folder() { let _ = std::fs::remove_dir_all(dir); } +/// 3D assets export as mesh files from the Asset Export panel: the Assets tab lists and checks the +/// picture assets alone. +#[test] +fn the_assets_tab_leaves_3d_assets_out() { + let (mut app, _, [a, b]) = app(false); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "params": {}})).unwrap(); + let three_d = app.run("assets.add3D", json!({"ids": [a]})).unwrap()["assets"][0].as_u64().unwrap(); + let picture = app.run("assets.add", json!({"ids": [b], "name": "Flat"})).unwrap()["assets"][0].as_u64().unwrap(); + app.run("assets.rename", json!({"asset": three_d, "name": "Solid"})).unwrap(); + app.run("file.exportForScreens", json!({})).unwrap(); + assert_eq!(field(&app, "assets"), json!([picture])); + set(&mut app, "tab", json!("assets")); + let text = painted_text(&mut app, |app, ui| super::show(app, ui.ctx())); + assert!(text.contains("Flat") && !text.contains("Solid"), "{text}"); + // Opened from the panel with both checked, the tab still shows the picture asset alone. + open_export_for_screens_assets(&mut app, Some(&[three_d, picture])); + assert_eq!(field(&app, "__scrollTo"), json!(picture), "the list scrolls to the first asset it shows"); + let text = painted_text(&mut app, |app, ui| super::show(app, ui.ctx())); + assert!(text.contains("Flat") && !text.contains("Solid"), "{text}"); +} + #[test] fn an_empty_assets_tab_says_how_to_collect_art() { let (mut app, _, _) = app(false); diff --git a/crates/ui-egui/src/dialogs/tests_screens.rs b/crates/ui-egui/src/dialogs/tests_screens.rs index 3bfda2ab2..140623f20 100644 --- a/crates/ui-egui/src/dialogs/tests_screens.rs +++ b/crates/ui-egui/src/dialogs/tests_screens.rs @@ -957,6 +957,34 @@ fn saving_an_applied_preset_keeps_its_bleed_and_fill() { assert_eq!((s.get("includeBleed"), s.get("includeArtboardFill")), (Some(&json!(true)), Some(&json!(false)))); } +/// Remembered settings and a preset that hold keys the export no longer reads, in a row, in a +/// format's settings and inside an object option, and settings for a format it doesn't write, open +/// without them, so the dialog's export is not refused. +#[test] +fn saved_keys_no_longer_read_are_left_out_of_the_dialog_s_export() { + let (mut app, _) = app(1, false); + let stale = json!({ + "formats": [{"format": "png", "scale": "2x", "noSuchOption": 1}, {"format": "pdf", "marks": {"trim": true, "noSuchMark": 1}}], + "settings": {"png": {"noSuchSetting": true}, "dxf": {}, "pdf": {"bleed": {"top": 1, "inner": 2}}} + }); + let Value::Object(saved) = stale.clone() else { panic!("an object") }; + std::sync::Arc::make_mut(&mut app.session.active_mut().unwrap().doc).export_settings = saved; + let folder = temp_folder("stale"); + let check = |app: &VectorcraftApp| { + let p = export_for_screens::params(app, app.ui.dialog.as_ref().unwrap()).unwrap(); + app.session.check_params("document.exportForScreens", &p).unwrap_or_else(|e| panic!("{e}: {p}")); + p + }; + open(&mut app); + set(&mut app, "folder", json!(folder.to_string_lossy())); + let p = check(&app); + assert_eq!(p["formats"][0]["scale"], json!("2x")); + assert_eq!(p["formats"][1]["marks"]["trim"], json!(true)); + export_for_screens::apply_preset(app.ui.dialog.as_mut().unwrap(), &json!({"name": "Old", "settings": stale})); + check(&app); + let _ = std::fs::remove_dir_all(&folder); +} + /// A preset that puts each item in its own folder (`item` or `itemFormat`, which the dialog has no /// control for) keeps that folder mode when it is exported or saved again from the dialog, until /// the sub-folder controls change (in the dialog or by `ui.dialog.set`). diff --git a/crates/ui-egui/src/dialogs/tests_shortcut_import.rs b/crates/ui-egui/src/dialogs/tests_shortcut_import.rs index 61bbc18e8..066f7f468 100644 --- a/crates/ui-egui/src/dialogs/tests_shortcut_import.rs +++ b/crates/ui-egui/src/dialogs/tests_shortcut_import.rs @@ -500,6 +500,25 @@ fn apply_rows(app: &mut VectorcraftApp, start: &str, rows: &[(&str, &str, &str, (out, dialog(app).fields["overrides"].clone()) } +#[test] +fn resetting_an_imported_function_key_restores_its_edit_default() { + if crate::keymap::platform().is_mac() { + return; + } + for (alias, edit) in [("F2", "edit.cut"), ("F3", "edit.copy"), ("F4", "edit.paste")] { + let mut app = app(); + let (out, value) = apply_rows(&mut app, "defaults", &[("Acme Duplicate", "Menus", "edit.duplicate", alias)]); + assert_eq!(out["applied"], 1); + let mut saved = crate::keymap::stored::from_value(value); + assert!(!crate::keymap::has_override(&saved, edit), "{alias} saved edit override"); + shortcut_editor::reset_one(&mut saved, "edit.duplicate"); + assert_eq!( + crate::keymap::lookup(&crate::keymap::keymap_with(&saved), &crate::keymap::vc_chord(alias).unwrap()), + Some(crate::keymap::command_of(edit)), + ); + } +} + /// Cmd and Ctrl are one key off the Mac: the review lets both rows through, and the later one takes /// the shortcut. #[test] @@ -577,6 +596,30 @@ fn long_names_match_and_apply() { assert_eq!((out["applied"].clone(), ov), (json!(1), json!({"edit.copy": "Cmd+Alt+Shift+F11"}))); } +/// A filter plug-in's row takes an imported shortcut when the user picks it, stored under its +/// `plugin.dialog {id}` key. A file's row with the plug-in's name does not match it by itself, and +/// a removed plug-in is not a target. +#[test] +fn an_imported_shortcut_reaches_a_filter_plugin_picked_as_its_target() { + use vectorcraft_plugins::wat as tpl; + let id = "test.UI.import_filter-one"; + let key = format!("plugin:{id}"); + let manifest = format!(r#"{{"id":"{id}","name":"Acme Import Filter","version":"1","kind":"filter","params":{{}}}}"#); + vectorcraft_plugins::registry::install_bytes(&wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap()).unwrap(); + assert!(shortcut_editor::entry(&key).is_some(), "a target the picker lists"); + assert!(super::shortcut_import::candidates().iter().all(|c| c.id != key), "a name alone does not match it"); + + let mut app = app(); + let (out, ov) = apply_rows(&mut app, "defaults", &[("Anything", "Menus", &key, "Cmd+Alt+Shift+F11")]); + assert_eq!(out["applied"], 1); + assert_eq!(ov, json!({ key.as_str(): "Cmd+Alt+Shift+F11" }), "not a binding of plugin.dialog without its id"); + let reloaded = crate::keymap::stored::from_value(ov); + assert_eq!(reloaded.get(&crate::keymap::stored_key(&key)).map(<[String]>::len), Some(1), "read back under the filter's id"); + + vectorcraft_plugins::registry::remove(id); + assert!(shortcut_editor::entries().iter().all(|e| e.key != key), "a removed filter is no target"); +} + /// A search in a row's target picker that matches nothing shrinks its menu to the search field /// and "No equivalent"; clearing the search lists every entry again, and the menu grows back to /// its height before the search (egui#5225). The search field stays where it was throughout. diff --git a/crates/ui-egui/src/dialogs/three_d_classic.rs b/crates/ui-egui/src/dialogs/three_d_classic.rs new file mode 100644 index 000000000..06d034fce --- /dev/null +++ b/crates/ui-egui/src/dialogs/three_d_classic.rs @@ -0,0 +1,85 @@ +//! Extrude & Bevel (Classic)'s own controls in the compact 3D dialog: the extrude and bevel options +//! on the Object tab, and the surface with its lights, highlight, blend steps and shading color on +//! the Lighting tab. Position (presets, rotation, perspective) is the Rotation tab every 3D effect +//! shares. + +use serde_json::{Value, json}; +use vectorcraft_doc::Unit; +use vectorcraft_effects::CLASSIC_EXTRUDE; + +use super::revolve::{c3, choice, heading, label, light_list, light_record, number, reset_dot, selected_light, store_selected, toggle}; +use crate::state::Dialog; + +/// Depth and cap, then the bevel: its shape, its height and whether it is added around the shape +/// or carved into it. +pub(super) fn object_controls(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { + heading(ui, tl!("Object")); + number(ui, d, "depth", tl!("Depth"), (0.0, 2000.0), "", 1, Some(unit)); + choice(ui, d, "cap", c3("Cap"), &[(json!(true), c3("Solid")), (json!(false), c3("Hollow"))]); + ui.add_space(10.0); + heading(ui, c3("Bevel")); + choice(ui, d, "bevel", tl!("Bevel Shape"), &options("bevel")); + ui.add_enabled_ui(d.str("bevel") != "none", |ui| { + number(ui, d, "bevelHeight", tl!("Height"), (1.0, 100.0), "", 1, Some(unit)); + toggle(ui, d, "bevelInside", tl!("Bevel Inside"), false); + }); +} + +/// The surface, then for Diffuse and Plastic the lights, Ambient Light, the highlight (Plastic +/// only), Blend Steps and Shading Color; Draw Hidden Faces last. +pub(super) fn surface_controls(ui: &mut egui::Ui, d: &mut Dialog) { + choice(ui, d, "surface", tl!("Surface"), &options("surface")); + let surface = d.str("surface"); + ui.add_enabled_ui(surface == "diffuse" || surface == "plastic", |ui| { + light_list(ui, d); + let mut selected = selected_light(d); + let before = light_record(&selected); + number(ui, &mut selected, "lightIntensity", tl!("Light Intensity"), (0.0, 100.0), "%", 1, None); + number(ui, &mut selected, "lightRotation", tl!("Rotation"), (-180.0, 180.0), "°", 1, None); + number(ui, &mut selected, "lightHeight", tl!("Height"), (0.0, 90.0), "°", 1, None); + toggle(ui, &mut selected, "lightBehind", tl!("Light behind the object"), false); + if light_record(&selected) != before { + store_selected(d, &selected); + } + number(ui, d, "ambient", tl!("Ambient Light"), (0.0, 100.0), "%", 1, None); + ui.add_enabled_ui(surface == "plastic", |ui| { + number(ui, d, "highlightIntensity", tl!("Highlight Intensity"), (0.0, 100.0), "%", 1, None); + number(ui, d, "highlightSize", tl!("Highlight Size"), (0.0, 100.0), "%", 1, None); + }); + number(ui, d, "blendSteps", tl!("Blend Steps"), (1.0, 256.0), "", 0, None); + choice(ui, d, "shadingColor", tl!("Shading Color"), &options("shadingColor")); + if d.str("shadingColor") == "custom" { + shading_custom(ui, d); + } + }); + toggle(ui, d, "drawHiddenFaces", tl!("Draw Hidden Faces"), false); +} + +/// The values of Extrude & Bevel (Classic)'s `key` as the dropdowns list them, (value, label). +fn options(key: &str) -> Vec<(Value, &'static str)> { + super::effect::own_choices(CLASSIC_EXTRUDE, key, false).unwrap_or_default().into_iter().map(|(label, value)| (json!(value), label)).collect() +} + +/// The custom shading color: a color button, its hex value and its reset dot. +fn shading_custom(ui: &mut egui::Ui, d: &mut Dialog) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + label(ui, tl!("Color")); + let color = d + .fields + .get("shadingCustom") + .and_then(Value::as_str) + .filter(|s| s.len() == 7 && s.starts_with('#')) + .and_then(vectorcraft_color::Color::from_hex) + .unwrap_or(vectorcraft_color::Color::BLACK); + let mut rgb = color.to_rgb_uncalibrated().map(|c| (c * 255.0).round() as u8); + if ui.color_edit_button_srgb(&mut rgb).on_hover_text(tl!("Shading Color")).changed() { + d.fields.insert("shadingCustom".into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); + } + ui.label(format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2])); + ui.add_space((ui.available_width() - 24.0).max(0.0)); + if reset_dot(ui, "shadingCustom", rgb != [0; 3], "#000000") { + d.fields.insert("shadingCustom".into(), json!("#000000")); + } + }); +} diff --git a/crates/ui-egui/src/doc_windows.rs b/crates/ui-egui/src/doc_windows.rs index 2f620a7df..619c563e4 100644 --- a/crates/ui-egui/src/doc_windows.rs +++ b/crates/ui-egui/src/doc_windows.rs @@ -340,6 +340,14 @@ fn native(app: &VectorcraftApp, ctx: &egui::Context) -> bool { true } +/// Gives the main window the keyboard when a floating document's OS window has it. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn focus_main_window(app: &VectorcraftApp, ctx: &egui::Context) { + if native(app, ctx) && app.ui.floating.iter().any(|w| w.focused) { + ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus); + } +} + /// The OS window of document window `w`. fn viewport_id(w: &DocWindow) -> egui::ViewportId { egui::ViewportId::from_hash_of(("vectorcraft-document-window", w.uid, w.number)) diff --git a/crates/ui-egui/src/doc_windows/tests.rs b/crates/ui-egui/src/doc_windows/tests.rs index 4311d8bbe..64a693a09 100644 --- a/crates/ui-egui/src/doc_windows/tests.rs +++ b/crates/ui-egui/src/doc_windows/tests.rs @@ -451,6 +451,41 @@ fn closing_an_os_window_keeps_it_while_its_unsaved_document_stays_open() { ); } +/// The update prompt, opening on its own, takes the keyboard once from a document's OS window that +/// has it, and leaves the keyboard alone when none of those windows has it. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn the_update_prompt_takes_the_keyboard_from_a_document_window() { + let _host = native_host(); + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + let mut app = app_with(2); + if !native(&app, &ctx) { + // Wayland: the windows are inside the main window. + return; + } + frame(&mut app, &ctx, vec![]); + app.run("window.arrange.floatInWindow", json!({})).unwrap(); + frame(&mut app, &ctx, vec![]); + let focus_commands = |app: &mut VectorcraftApp| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::updates_ui::show(app, ui.ctx())); + out.textures_delta.clear(); + out.viewport_output.get(&egui::ViewportId::ROOT).map_or(0, |v| v.commands.iter().filter(|c| **c == egui::ViewportCommand::Focus).count()) + }; + + app.ui.floating[0].focused = true; + assert_eq!(focus_commands(&mut app), 0, "no prompt is open"); + crate::updates_ui::open_prompt_for_test(&mut app); + assert_eq!(focus_commands(&mut app), 1, "the main window gets the keyboard"); + assert_eq!(focus_commands(&mut app), 0, "once"); + + crate::updates_ui::close_dialogs_for_test(&mut app); + assert_eq!(focus_commands(&mut app), 0, "the prompt closed"); + app.ui.floating[0].focused = false; + crate::updates_ui::open_prompt_for_test(&mut app); + assert_eq!(focus_commands(&mut app), 0, "no document window has the keyboard"); +} + /// Where the last frame drew the title of `title`'s tab in the tab strip. fn tab_at(shapes: &[Shape], title: &str) -> Pos2 { shapes diff --git a/crates/ui-egui/src/edit_toolbar.rs b/crates/ui-egui/src/edit_toolbar.rs index 1a4c54989..2d6fede3b 100644 --- a/crates/ui-egui/src/edit_toolbar.rs +++ b/crates/ui-egui/src/edit_toolbar.rs @@ -467,7 +467,7 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) { let screen = ctx.content_rect(); let id = drawer_id(); let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(WIDTH, 200.0), |r| r.size()); - let x = (bar.right() + GAP).min(screen.right() - size.x - GAP).max(screen.left()); + let x = crate::toolbar::beside(bar, size.x, GAP, crate::toolbar::room(app, ctx).shrink(GAP)); // Never above the toolbar's top: higher up are the window's title bar (and on macOS its // buttons) and the bars under it. let top = bar.top().max(screen.top() + GAP); diff --git a/crates/ui-egui/src/find_font.rs b/crates/ui-egui/src/find_font.rs index e73d78736..0c17c6e92 100644 --- a/crates/ui-egui/src/find_font.rs +++ b/crates/ui-egui/src/find_font.rs @@ -1,7 +1,8 @@ //! Type → Find Font…: the fonts the document uses (missing ones marked), Find (select the text //! using a font), Change / Change All to another font from the list Replace With Font From shows //! (the document's fonts, the recent fonts or every installed font), and Find in Folder… (the -//! missing fonts' files, in the Missing Fonts dialog: `ui.findFontsInFolder`). +//! missing fonts' files, in the Missing Fonts dialog: `ui.findFontsInFolder`). Find In limits Find +//! to the active artboard or the selected layers. use egui::Ui; use serde_json::{Value, json}; @@ -23,9 +24,10 @@ pub fn open_on_missing(app: &mut VectorcraftApp) { open_at(app, first.unwrap_or(0)); } -/// Open the dialog with the document's font `selected` (an index into `text.fonts`) chosen, and -/// System's font ([`system_family`]) to change to. +/// Open the dialog with the document's font `selected` (an index into `text.fonts`) chosen, +/// System's font ([`system_family`]) to change to, and the last Find In choice (Document at first). fn open_at(app: &mut VectorcraftApp, selected: usize) { + let find_in = FIND_IN.iter().find(|f| **f == app.ui.find_font_in).copied().unwrap_or("document"); let used = fonts(app); let source = "system"; let rows = replacement_rows(app, source, &used); @@ -33,7 +35,7 @@ fn open_at(app: &mut VectorcraftApp, selected: usize) { let style = rows.iter().find(|r| r.family.eq_ignore_ascii_case(&family)).map(|r| r.style.clone()).unwrap_or_default(); app.ui.dialog = Some(Dialog::new( "findFont", - json!({ "selected": selected, "family": family, "style": style, "selectionOnly": false, "replaceFrom": source, "rows": rows_json(&rows) }), + json!({ "selected": selected, "family": family, "style": style, "selectionOnly": false, "replaceFrom": source, "rows": rows_json(&rows), "findIn": find_in }), )); } @@ -210,6 +212,42 @@ fn set_source(app: &VectorcraftApp, d: &mut Dialog, source: &str, used: &[Value] d.fields.insert("style".into(), json!(pick.map(|r| r.style.clone()).unwrap_or_default())); } +/// Find In's choices (the `findIn` field): the whole document, the active artboard, or the layers +/// highlighted in the Layers panel (else the current layer). +const FIND_IN: [&str; 3] = ["document", "artboard", "layers"]; +const FIND_IN_LABELS: [&str; 3] = ["Document", "Artboard", "Layers"]; + +/// The `scope` Find passes `select.font` for Find In choice `find_in`. A document without +/// artboards, or without a layer to look in, is looked through whole. +fn find_scope(app: &VectorcraftApp, find_in: &str) -> Value { + let Some(st) = app.session.active() else { return json!("document") }; + let n = st.doc.artboards.len(); + match find_in { + "artboard" if n > 0 => json!({ "artboards": [crate::panels::artboards::selected(app, n)] }), + "layers" => { + let is_layer = |id: &vectorcraft_doc::NodeId| st.doc.node(*id).is_some_and(vectorcraft_doc::Node::is_layer); + let mut layers: Vec = st.highlighted_rows().into_iter().filter(is_layer).map(|id| id.0).collect(); + if layers.is_empty() { + layers.extend(st.current_layer().map(|id| id.0)); + } + if layers.is_empty() { json!("document") } else { json!({ "layers": layers }) } + } + _ => json!("document"), + } +} + +/// Run the dialog's button `button` (find, change or changeAll) on the document font `from`. +fn run_button(app: &mut VectorcraftApp, d: &Dialog, button: &str, from: &Value) -> Result { + match button { + "find" => { + let scope = find_scope(app, &d.str("findIn")); + app.run("select.font", json!({ "family": from["family"], "style": from["style"], "scope": scope })) + } + "change" => change(app, d, from, false), + _ => change(app, d, from, true), + } +} + fn fonts(app: &mut VectorcraftApp) -> Vec { app.session.execute("text.fonts", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default() } @@ -342,6 +380,14 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } } ui.add_space(14.0); + ui.horizontal(|ui| { + widgets::field_label(ui, tl!("Find In")); + let at = FIND_IN.iter().position(|s| *s == d.str("findIn")).unwrap_or(0); + if let Some(i) = widgets::dropdown_named(ui, "ff-find-in", Some(tl!("Find In")), FIND_IN_LABELS[at], &FIND_IN_LABELS, 120.0) { + d.fields.insert("findIn".into(), json!(FIND_IN[i])); + } + }); + ui.add_space(6.0); // An empty list (Recent before any font was applied) leaves nothing to change to. let replace = sel < list.len() && can_replace(&d, families.as_deref()); ui.horizontal(|ui| { @@ -362,14 +408,10 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { }); }); }); + app.ui.find_font_in = d.str("findIn"); let sel = d.fields.get("selected").and_then(Value::as_u64).unwrap_or(0) as usize; if let (Some(a), Some(from)) = (act, list.get(sel)) { - let r = match a { - "find" => app.run("select.font", json!({ "family": from["family"], "style": from["style"] })), - "change" => change(app, &d, from, false), - _ => change(app, &d, from, true), - }; - match r { + match run_button(app, &d, a, from) { // The rows are a snapshot: the document's fonts and the recent fonts have moved on. Ok(_) if a != "find" => { let used = fonts(app); @@ -433,6 +475,48 @@ mod tests { assert_eq!(fonts(&mut app)[0]["family"], "Source Sans 3"); } + /// Find In passes Find the active artboard, or the selected layers (else the current + /// layer), as `select.font`'s scope. + #[test] + fn find_in_limits_find_to_the_artboard_or_the_layers() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + app.session.execute("artboard.new", &json!({"x": 200, "y": 0, "width": 100, "height": 100})).unwrap(); + let text = |app: &mut VectorcraftApp, x: i32| { + app.session.execute("text.create", &json!({"x": x, "y": 40, "text": "Hi", "font": "Inter"})).unwrap()["id"].as_u64().unwrap() + }; + let first = text(&mut app, 10); + let layer = app.session.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap(); + let second = text(&mut app, 210); + let third = text(&mut app, 20); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + // Choose Find In on the open dialog, draw a frame, then press Find. + let find = |app: &mut VectorcraftApp, find_in: &str| { + open(app); + app.ui.dialog.as_mut().unwrap().fields.insert("findIn".into(), json!(find_in)); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + let d = app.ui.dialog.clone().unwrap(); + let from = fonts(app).into_iter().find(|f| f["family"] == "Inter").unwrap(); + run_button(app, &d, "find", &from).unwrap(); + let mut ids: Vec = app.session.active().unwrap().selection.objects.iter().map(|id| id.0).collect(); + ids.sort_unstable(); + ids + }; + open(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("findIn"), "document"); + assert_eq!(find(&mut app, "document"), [first, second, third]); + // Without a view, the active artboard is the first. + assert_eq!(find(&mut app, "artboard"), [first, third]); + assert_eq!(find_scope(&app, "layers"), json!({ "layers": [layer] })); + assert_eq!(find(&mut app, "layers"), [second, third]); + // Closing and opening the dialog again keeps the last choice. + app.ui.dialog = None; + open(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("findIn"), "layers"); + } + /// Change All leaves locked text in its font, and the status bar says so. #[test] fn change_all_clears_the_skip_warning_after_unlocking_text() { @@ -1003,7 +1087,8 @@ mod tests { let system = replacement_rows(&app, "system", &used); assert!(system.iter().any(|r| r.family == "Source Sans 3" && r.style == "Regular"), "{system:?}"); assert!(!system.iter().any(|r| r.family.starts_with('.')), "the font menu's hidden families stay out"); - assert!(system.iter().all(|r| !r.style.is_empty()), "no nameless rows: {system:?}"); + // A composite font is a family without styles of its own, listed by its name alone. + assert!(system.iter().all(|r| !r.style.is_empty() || db.styles(&r.family).is_empty()), "no nameless rows: {system:?}"); let menu = db.menu_family_list(); let listed: std::collections::BTreeSet<&str> = system.iter().map(|r| r.family.as_str()).collect(); assert_eq!(listed.len(), menu.len(), "every family the font menu shows is listed"); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e9a219bd5..ceb10bb4d 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -184,6 +184,10 @@ Anti-Alias Заглаждане Anti-alias Rasters Заглаждане на растери Anti-aliased Artwork Загладено произведение + Anti-aliasing Method Метод на заглаждане +antiAlias Sharp Остро + Crisp Четко + Strong Силно Anti-aliasing: Заглаждане: Any Kind Всякакъв вид Font Technology Технология на шрифта @@ -354,6 +358,13 @@ font-technology Variable Променливи Apply Bullets Прилагане на булети Numbered List Номериран списък Apply Numbers Прилагане на номериране + Bullets and Numbering… Булети и номериране… + Numbers Номера + Level: Ниво: + Bullet Character: Знак за булет: + Number Style: Стил на номерата: + Left Indent: Ляв отстъп: + First Line Indent: Отстъп на първия ред: Convert to Text Конвертиране в текст Filled Dot Плътна точка Dash Тире @@ -540,6 +551,11 @@ font-technology Variable Променливи Color Table ({n} colors) Цветова таблица ({n} цвята) Color Theme Цветова тема Color Themes Цветови теми + Color Wheel Цветно колело + Hue ring Пръстен на оттенъка + Saturation and brightness Насищане и яркост + Color field Цветово поле + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Цветен тип: Color: Цвят: Colorize Оцветяване @@ -1057,6 +1073,9 @@ font-technology Variable Променливи Export As SVG… Експортиране като SVG… Export Asset Експортиране на актив Export Assets Експортиране на активи + Export 3D Object Експортиране на 3D обект + 3D Export Format Формат на 3D експортиране + Export 3D Assets Експортиране на 3D активи Export As… Експортиране като… Export CSS Експортиране на CSS Export CSS… Експортиране на CSS… @@ -1088,6 +1107,17 @@ font-technology Variable Променливи Exporting {} Експортиране на {} Export… Експортиране… Extrude & Bevel… Избутване и фасетка… + 3D (Classic) 3D (класически) + Extrude & Bevel (Classic)… Избутване и фасетка (класически)… + Surface Повърхност + Wireframe Телена рамка + No Shading Без засенчване + Plastic Пластмаса + Highlight Intensity Интензивност на отблясъка + Highlight Size Размер на отблясъка + Blend Steps Стъпки на преливане + Shading Color Цвят на засенчването + Draw Hidden Faces Изчертаване на скритите страни Eyedropper Пипета Eyedropper Applies: Пипетата прилага: Eyedropper Options Опции за пипетата @@ -1130,6 +1160,7 @@ font-technology Variable Променливи Live curve fitting Напасване на кривата в реално време Fill Color Цвят на запълване Fill New Type Objects With Placeholder Text Запълване на нови текстови обекти с примесен текст + Start Lists From Typed Markers Започване на списъци от въведени маркери Fill Rule Правило за запълване Fill with Placeholder Text Запълване с примесен текст Fill: Запълване: @@ -1140,6 +1171,7 @@ font-technology Variable Променливи Find Font… Търсене на шрифт… Find Next Търсене на следващ Find Objects Търсене на обекти + Compare with Image Сравняване с изображение First Page Първа страница First-line Left Indent Ляв отстъп на първи ред Fisheye… Рибено око… @@ -1664,6 +1696,7 @@ font-technology Variable Променливи Report copied Отчетът е копиран Line {line} Ред {line} Line {line}: {name}: {status} Ред {line}: {name}: {status} + Read Action Set File Четене на файл с набор от действия Read Shortcut Set File Четене на файл с набор от клавишни комбинации Import Matches Импортиране на съответствия Remember Import Matches Запомняне на съответствията при импортиране @@ -1898,6 +1931,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 на монитора @@ -2693,6 +2730,7 @@ digits Malayalam Малаялам Reset Transformation Нулиране на трансформацията Replace With Font Замяна с шрифт Replace With Font From Замяна с шрифт от + Find In Търсене в System Система Replaces the saved preset of that name. Заменя записаната предварителна настройка с това име. Replace… Замяна… @@ -2978,6 +3016,11 @@ digits Malayalam Малаялам Selected Swatches Избрани образци Selected Swatches Only Само избрани образци Selection Селекция +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Избор с рамка + Touching Докоснати обекти + Fully Enclosed Изцяло вътре + Drag Direction Посока на плъзгане Selection & Anchor Display Показване на селекция и опорни точки Selection Only Само селекция Selection Tool Инструмент „Селекция“ @@ -3113,6 +3156,7 @@ digits Malayalam Малаялам Simulate Colored Paper Симулиране на цветна хартия Simulate Paper Симулиране на хартия Double Column Две колони + Keep Tools Beside the Canvas Инструментите до платното Single Left Quotation Mark Лява единична кавичка Single Quotes: Единични кавички: Single Right Quotation Mark Дясна единична кавичка @@ -3362,6 +3406,15 @@ digits Malayalam Малаялам Tate-chu-yoko Тате-чу-йоко Tate-chu-yoko Settings… Настройки на Тате-чу-йоко… Tate-chu-yoko Settings Настройки на Тате-чу-йоко + Composite Fonts… Съставни шрифтове… + Composite Fonts Съставни шрифтове + Export Composite Fonts Експортиране на съставни шрифтове + Import Composite Fonts Импортиране на съставни шрифтове + Delete Font Изтриване на шрифт + Kanji Канджи + Kana Кана + Numerals Цифри + Scale at Center Мащабиране от центъра Up/Down: Нагоре/надолу: Left/Right: Наляво/надясно: Temperature Температура @@ -3415,6 +3468,7 @@ digits Malayalam Малаялам Tile Type: Тип плочка: tile {number} плочка {number} Tiles are numbered across, then down; leave the range empty for every tile. Плочките се номерират напречно, след това надолу; оставете диапазона празен за всяка плочка. + Copies per Sheet (as many as fit, in a grid) Копия на лист (колкото се побират, в мрежа) Tint Оттенък Tip extends past the end point Връхът продължава след крайната точка Tip on the end point Връх върху крайната точка @@ -3709,6 +3763,7 @@ digits Malayalam Малаялам Zoom to Selection Приближаване до селекцията Zoom Tool Инструмент „Мащаб“ Zoom with Mouse Wheel Мащабиране с колелцето на мишката + Scroll Sideways with Cmd/Ctrl + Wheel Хоризонтално превъртане с Cmd/Ctrl + колелцето Scrub Numeric Fields by Dragging Плъзгане на числени полета чрез влачене @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} файл в {folder} {note}. Показване на папката на пакета?|{n} файла в {folder} {note}. Показване на папката на пакета? @plural {n} linked file not found|{n} linked files not found {n} свързан файл не е намерен|{n} свързани файла не са намерени @@ -3873,11 +3928,11 @@ axis Both И двете Dock to Bottom Закотвяне долу Dock Control Bar Закотвяне на контролната лента Drag to float the toolbar Плъзнете, за да направите лентата с инструменти плаваща - Drag to the window's left edge to dock Плъзнете до левия ръб на прозореца за закотвяне + Drag to the window's left or right edge to dock Плъзнете до левия или десния ръб на прозореца за закотвяне Float Tool Group Плаваща група инструменти @msg no library `{id}` няма библиотека `{id}` @msg select some type first първо изберете текст -@msg kerning must be a number, "auto", or "optical" Кернингът трябва да е число, „auto“ или „optical“ +@msg kerning must be a number, "auto", or "optical" kerning трябва да е число, „auto“ или „optical“ @msg {_1} has no {_2} style {_1} няма стил {_2} @msg a layer couldn't be read слой не можа да бъде прочетен @msg it has more than one artboard има повече от една работна област @@ -3964,6 +4019,7 @@ axis Both И двете @msg Targa files are RGB: colorModel rgb Файловете Targa са RGB: colorModel rgb @msg Text Wrap: select a wrap object Обгръщане на текст: изберете обгръщащ обект @msg This shortcut file is in a form VectorCraft can't read. Този файл с преки пътища е във формат, който VectorCraft не може да прочете. +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` трябва да е "none", "sharp", "crisp" или "strong" @msg `aspect` must be a number (%) `aspect` трябва да бъде число (%) @msg `at` must be [x, y] `at` трябва да бъде [x, y] @msg `color` must be a colour `color` трябва да бъде цвят @@ -4054,10 +4110,10 @@ axis Both И двете @msg already editing a pattern мострата вече се редактира @msg an object can't be read обектът не може да бъде прочетен @msg an object in perspective would leave the edited grid обект в перспектива би напуснал редактираната мрежа -@msg antiAlias `{id}`: none, art or type заглаждане `{id}`: няма, художествено или текстово +@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art или type @msg artboard {0} does not exist работна област {0} не съществува @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard при този мащаб чертежът е {w} × {h} pt, повече отколкото платното побира: изберете по-малък мащаб или го вгонесете в работната област -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` оста трябва да бъде "vertical", "horizontal" или ъгъл в градуси, а не `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis трябва да бъде "vertical", "horizontal" или ъгъл в градуси, а не `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" фон {other}: прозрачен, бял, черен или цвят като "#ff8800" @msg bad color {v} грешен цвят {v} @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio трябва да бъде число от 0,1 до 10 @@ -4066,9 +4122,9 @@ axis Both И двете @msg bad shortcut грешен клавишен бърз път @msg bad stream length грешна дължина на потока @msg bad trailer грешен край (trailer) -@msg balance must be between 0 and 100 балансът трябва да бъде между 0 и 100 +@msg balance must be between 0 and 100 balance трябва да бъде между 0 и 100 @msg batch step {_1} (`{id}`) failed: {e} стъпка от пакет {_1} (`{id}`) неуспешна: {e} -@msg brightness must be dark|mediumDark|mediumLight|light яркостта трябва да бъде dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light brightness трябва да бъде dark|mediumDark|mediumLight|light @msg can't encode the document: {0} документът не може да бъде кодиран: {0} @msg can't parse shortcut `{c}` клавишният бърз път `{c}` не може да бъде разпознат @msg can't parse shortcut `{s}` for `{k}` клавишният бърз път `{s}` за `{k}` не може да бъде разпознат @@ -4093,7 +4149,7 @@ axis Both И двете @msg compound paths can only contain paths съставните пътища могат да съдържат само пътища @msg compressed (RLE) bitmaps can't be read yet компресираните (RLE) битови карти все още не могат да бъдат четени @msg damaged повреден -@msg decimals must be {_1}–{_2} (got {_3}) десетичните знаци трябва да бъдат {_1}–{_2} (получени {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals трябва да бъде {_1}–{_2} (получено {_3}) @msg divide into 1 to {MAX_PIECES} slices разделяне на 1 до {MAX_PIECES} парчета @msg effect {k} is `{_1}`, not `{id}` ефектът {k} е `{_1}`, а не `{id}` @msg empty brush stroke празен щрих с четка @@ -4111,9 +4167,9 @@ axis Both И двете @msg an image has no pixels and was left out изображение без пиксели беше пропуснато @msg image data `{_1}` is missing данните за изображение `{_1}` липсват @msg image would be too large; lower the resolution изображението ще бъде твърде голямо; намалете разделителната способност -@msg index must be a number or first, previous, next or last индексът трябва да е число или first, previous, next или last -@msg index must be a number or first, previous, next or last, not `{other}` индексът трябва да е число или first, previous, next или last, а не `{other}` -@msg index must be ≥ 0 индексът не може да е отрицателен +@msg index must be a number or first, previous, next or last index трябва да е число или first, previous, next или last +@msg index must be a number or first, previous, next or last, not `{other}` index трябва да е число или first, previous, next или last, а не `{other}` +@msg index must be ≥ 0 index трябва да е ≥ 0 @msg installing from a path is not available on the web; pass the module as `dataBase64` инсталирането от път не е налично в уеб; предайте модула като `dataBase64` @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) вътрешна грешка в `{cmd}`: {msg} (документът беше оставен в състоянието си отпреди; моля, докладвайте тази грешка) @msg internal error: {msg} (please report this bug) вътрешна грешка: {msg} (моля, докладвайте тази грешка) @@ -4213,6 +4269,8 @@ axis Both И двете @msg no such action set няма такъв набор от действия @msg the action sets changed; nothing was deleted наборите от действия се промениха; нищо не беше изтрито @msg no action set to save няма набор от действия за запазване +@msg the action set file can't be read in full файлът с набор от действия не може да се прочете изцяло +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Зареден е „{name}“: {n} стъпка е пропусната|Зареден е „{name}“: {n} стъпки са пропуснати @msg not an action set file не е файл с набор от действия @msg the action set file is larger than 8 MiB файлът с набор от действия е по-голям от 8 MiB @msg the action set file holds more than 1,000 actions файлът с набор от действия съдържа повече от 1000 действия @@ -4285,9 +4343,9 @@ axis Both И двете @msg replace: the object to replace was changed or deleted замяна: обектът за замяна е променен или изтрит @msg pattern missing липсва мостра @msg patterns nested too deeply are filled with mid-grey мострите, вложени прекалено дълбоко, се запълват със средно сиво -@msg percent must be above 0 and at most 6400, not {p} процентът трябва да е над 0 и най-много 6400, а не {p} +@msg percent must be above 0 and at most 6400, not {p} percent трябва да е над 0 и най-много 6400, а не {p} @msg pick a colour, gradient or pattern to save as a swatch изберете цвят, градиент или мостра за запазване като образец -@msg plane must be left, right or ground равнината трябва да е лява, дясна или земна +@msg plane must be left, right or ground plane трябва да е left, right или ground @msg plug-in ABI: {0} ABI на плъгин: {0} @msg plug-in exceeded its {0} плъгинът надвиши своя {0} @msg plug-in failed: {0} плъгинът се провали: {0} @@ -4306,8 +4364,8 @@ axis Both И двете @msg result would exceed the canvas (coordinates out of range) резултатът ще надвиши платното (координатите са извън обхвата) @msg objects can't be nested more than 32 deep обектите не могат да бъдат вложени на повече от 32 нива @msg the SVG's elements are nested more than {0} deep елементите на SVG са вложени на повече от {0} нива -@msg scale must be a number from {_1} to {_2}, not {_3} мащабът трябва да е число от {_1} до {_2}, а не {_3} -@msg scale must be a number from {_1} to {_2}, not {s} мащабът трябва да е число от {_1} до {_2}, а не {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale трябва да е число от {_1} до {_2}, а не {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale трябва да е число от {_1} до {_2}, а не {s} @msg select (or target) objects whose appearance isn't basic изберете (или насочете към) обекти, чийто изглед не е основен @msg select a Live Paint group изберете група за интерактивно оцветяване (Live Paint) @msg select a blend изберете преход (blend) @@ -4560,10 +4618,14 @@ axis Both И двете @msg its objects are nested too deeply обектите му са вложени твърде дълбоко @msg its section ending with `%{end}` doesn't end разделът му, завършващ на `%{end}`, няма край @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) четките, които VectorCraft все още не може да прочете, бяха пропуснати от панела „Четки“ ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet обектите с множество запълвания или щрихове, ефекти или четки се появиха във вид на изрисувания си изглед ({_1}): тяхната поява все още не подлежи на редактиране +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet обектите с множество запълвания или щрихове, щрихове с променлива ширина, ефекти или четки се появиха във вид на изрисувания си изглед ({_1}): тяхната поява все още не подлежи на редактиране +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding името `{name}` не е в UTF-8 и е прочетено като {label}: ако изглежда грешно, отворете файла с друго legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding името `{name}` не е в UTF-8, а части от него не са валидни и в {label}: отворете файла с друго legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) още имена, които не са в UTF-8, са прочетени като {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it градиентът „{name}“ не е дефиниран във файла: заменен е с обикновен цвят @msg an image VectorCraft can't read was left out изображение, което VectorCraft не може да разчете, беше пропуснато @msg an image whose data is cut short was left out изображение с непълни данни беше пропуснато +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers вградено изображение от {_1} × {_2} пиксела беше намалено до {_3} × {_4} пиксела, за да се запазят слоевете @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel свързаните картини показват записания във файла предварителен преглед с ниска резолюция, докато файловете им бъдат намерени: презакачете ги в панела „Връзки“ @msg linked type areas past VectorCraft's limits were left out свързаните текстови области извън ограниченията на VectorCraft бяха пропуснати @msg type that changes writing direction inside a text object opens in the direction of its first character текст, който сменя посоката на писане в рамките на текстов обект, се отваря в посоката на първия си знак @@ -4640,6 +4702,7 @@ axis Both И двете @msg object ids are non-negative integers, not {v} идентификаторите на обектите са неотрицателни цели числа, а не {v} @msg {key} must be an array of non-negative integer object ids {key} трябва да бъде масив от идентификатори на обекти, които са неотрицателни цели числа @msg invalid object id {value}: expected a non-negative integer невалиден идентификатор на обект {value}: очаква се неотрицателно цяло число +@msg `{key}` entry {index} is {value}, not a non-negative integer object id елемент {index} на `{key}` е {value}, а не идентификатор на обект (неотрицателно цяло число) @msg no such object id {value} няма обект с идентификатор {value} @msg automation filesystem access is not granted: read authority is absent: {path} достъпът на автоматизацията до файловата система не е разрешен: липсва право за четене: {path} @msg automation filesystem access is not granted: write authority is absent: {path} достъпът на автоматизацията до файловата система не е разрешен: липсва право за запис: {path} @@ -4718,6 +4781,9 @@ axis Both И двете effect Soft Меко Edge Роб System Title Bar Заглавна лента на системата + VectorCraft Title Bar on Every Desktop Заглавна лента на VectorCraft на всеки работен плот + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Докато е зададена, променливата на средата VECTORCRAFT_TITLEBAR избира заглавната лента. + System Title Bar takes precedence over this option while it is on. Докато е включена, „Заглавна лента на системата“ има предимство пред тази опция. Value Axis Ос на стойностите On Left Side От лявата страна On Right Side От дясната страна @@ -4758,24 +4824,24 @@ effect Soft Меко Resize panels Преоразмеряване на панели Resize panel window Преоразмеряване на прозореца на панела @msg Invalid panel layout; restored the default dock Невалидно оформление на панела; възстановено е подразбиращото се разположение -@msg action is required необходимо е действие -@msg anchor must name a registered panel опорната точка трябва да указва регистриран панел -@msg before must name a panel параметърът „преди“ трябва да указва панел +@msg action is required action е задължителен +@msg anchor must name a registered panel anchor трябва да указва регистриран панел +@msg before must name a panel before трябва да указва панел @msg docking action contains an unknown panel or an excessive split path действието по закачане съдържа непознат панел или прекомерен път на разделяне @msg group must be true or false group трябва да е true или false -@msg panel is not in a dock group панелът не е в група на дока +@msg panel is not in a dock group panel не е в група на дока @msg only a dock group above or below others collapses, not one beside another свива се само група на дока над или под други, не и такава до друга @msg icon must be true or false icon трябва да е true или false @msg icon only docks a panel in the icon column, with no anchor icon закачва панел само в колоната с икони, без anchor @msg column docks a set as a dock column, with no anchor or icon column закачва набор като колона на дока, без anchor или icon @msg move requires a destination panel преместването изисква целеви панел @msg move requires an existing destination panel преместването изисква съществуващ целеви панел -@msg onto must name a floating panel параметърът „върху“ трябва да указва плаващ панел +@msg onto must name a floating panel onto трябва да указва плаващ панел @msg panel group is empty групата от панели е празна -@msg panel must name a registered panel панелът трябва да указва регистриран панел +@msg panel must name a registered panel panel трябва да указва регистриран панел @msg saved panel layout is invalid; reset the workspace first запазеното оформление на панелите е невалидно; нулирайте работното пространство първо -@msg zone must be a string зоната трябва да е низ -@msg zone must be tab, center, left, right, top or bottom зоната трябва да бъде tab, center, left, right, top или bottom +@msg zone must be a string zone трябва да е низ +@msg zone must be tab, center, left, right, top or bottom zone трябва да бъде tab, center, left, right, top или bottom @msg {key} must be a finite coordinate {key} трябва да е крайна координата @msg x and y must be provided together x и y трябва да бъдат подадени заедно @msg invalid restored panel group невалидна възстановена група от панели @@ -5357,7 +5423,7 @@ theme Appearance Външен вид Screen Mode Controls Контроли за режим на екран Drawer menu Меню на чекмеджето Toolbar: Show Controls Лента с инструменти: Показване на контроли -@msg control must be fillStroke, coloring, drawMode or screenMode контролът трябва да бъде fillStroke, coloring, drawMode или screenMode +@msg control must be fillStroke, coloring, drawMode or screenMode control трябва да бъде fillStroke, coloring, drawMode или screenMode @msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode неизвестен контрол `{name}`: fillStroke, coloring, drawMode или screenMode @msg give a fraction `t` from 0 to 1 въведете дробно число `t` от 0 до 1 @msg give the path `id` въведете `id` на пътя @@ -5710,6 +5776,8 @@ axis Y Axis Y ос Install “{name}”, which asks for these permissions? Да се инсталира ли „{name}“, който иска тези права? ID: {id} ID: {id} Version: {version} Версия: {version} + Install “{name}” in place of the plug-in with the same ID? Да се инсталира ли „{name}“ на мястото на приставката със същия ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Този файл не е приставката, инсталирана преди с този ID. Той получава настройките и кеша, пазени за този ID. File SHA-256: {hash} SHA-256 на файла: {hash} Read the objects it is given Четене на обектите, които получава Change, add and delete objects in the document Промяна, добавяне и изтриване на обекти в документа @@ -5717,6 +5785,9 @@ 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 Записване на документа като файл в собствен формат Show its progress, see Cancel and write log lines Показване на напредъка, разпознаване на отказ и записване на редове в дневника + Keep settings of its own Съхраняване на собствени настройки + Keep a cache of its own, which you can clear Съхраняване на собствен кеш, който можете да изчистите + Clear Plug-in Cache Изчистване на кеша на добавката # Actions panel: action sets (#3712). New Set… Нов набор… New Set Нов набор @@ -6098,6 +6169,8 @@ breakText Glyphs Глифи File + Data Set Name Файл + име на набора от данни Data Set Name Име на набора от данни @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` трябва да е `fileNumber`, `fileDataSetName` или `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Всеки набор от данни се записва върху същия файл, в който остава само последният набор от данни. +@msg save the document with Save As before Save and Close запазете документа със „Запази като“ преди „Запази и затвори“ @msg no free data set file name in destination folder Няма свободно име за файл на набор от данни в целевата папка # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6218,9 +6291,20 @@ breakText Glyphs Глифи Reset Symbol Instance Нулиране на екземпляра на символ @msg type must be static or dynamic, not {v} type трябва да е static или dynamic, а не {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` е статичен символ: първо задайте типа на символа на „Динамичен“ +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change На път, съставен път или текст, избран в екземпляр на символ, могат да се променят само плътните цветове на запълването и контура @msg path must be a list of child indices path трябва да е списък с индекси на дъщерни обекти @msg no object at path {shown} in the symbol's art в графиката на символа няма обект на пътя {shown} @msg give fill, stroke, opacity and/or blend задайте fill, stroke, opacity и/или blend +@msg {key} cannot be set on a guide {key} не може да се задава за водач +@msg {key} cannot be set on a blend {key} не може да се задава за преливане +@msg {key} cannot be set on an envelope {key} не може да се задава за плик +@msg {key} cannot be set on a gradient mesh {key} не може да се задава за мрежа с градиент +@msg {key} cannot be set on a Repeat {key} не може да се задава за повтаряне +@msg {key} cannot be set on a placed document {key} не може да се задава за вмъкнат документ +@msg {key} cannot be set on an Intertwine group {key} не може да се задава за група с преплитане +@msg stroke cannot be set on a symbol instance stroke не може да се задава за екземпляр на символ +@msg fill on a symbol instance must be a solid color or none fill на екземпляр на символ трябва да е плътен цвят или none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} не може да се задава за изображение без ефект „Конвертиране в фигура“ или „Преобразуване на обекта в контур“ @msg pass newName, registration, exportType, nineSlice and/or type задайте newName, registration, exportType, nineSlice и/или type Text Flow Поток на текста By Rows По редове @@ -6250,6 +6334,7 @@ breakText Glyphs Глифи Create Swatch Legend Създаване на легенда на образците Create Swatch Legend… Създаване на легенда на образците… HEX HEX + Help Bar Лента за помощ # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Инструменти за текст Text Tweaker… Настройка на текста… @@ -6315,3 +6400,30 @@ 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 Четене на файлове, които отваряте в неговия формат, и създаване на документи от тях + +# Safe Mode + Safe Mode… Безопасен режим… + Safe Mode Безопасен режим + Help › Safe Mode… Помощ › Безопасен режим… + Enable on Re-launch Включване при следващо стартиране + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Последното стартиране на VectorCraft спря, докато се зареждаше добавката {name}. Възможно е тя да не е причината. + Disable and Continue Изключване и продължаване + Start Normally Нормално стартиране + Start in Safe Mode Стартиране в безопасен режим + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft е в безопасен режим: добавките не се зареждат и не се изпълняват, докато не стартира нормално. + Disabled plug-ins Изключени добавки + No plug-ins are disabled. Няма изключени добавки. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Безопасният режим е включен: добавките не се зареждат и не се изпълняват, докато VectorCraft не стартира нормално +@msg give `id` or `file` (see plugin.safeMode) задайте `id` или `file` (вижте plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) тази добавка не е изключена (вижте plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch Изключените добавки не можаха да бъдат запазени за следващото стартиране +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Разширяване на облика: ефект на добавка в селекцията не може да бъде разширен в безопасен режим + +# Align to Pixel Grid + Align to Pixel Grid Подравняване към пикселната мрежа +@msg side is left or right, not `{s}` side е left или right, а не `{s}` diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index ca52bf517..d70d67c90 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -142,6 +142,7 @@ Report copied Izvještaj kopiran Line {line} Red {line} Line {line}: {name}: {status} Red {line}: {name}: {status} + Read Action Set File Pročitaj datoteku skupa akcija Read Shortcut Set File Pročitaj datoteku skupa prečica Import Matches Uparivanja uvoza Remember Import Matches Zapamti uparivanja uvoza @@ -179,6 +180,11 @@ Ungroup Razgrupiši Lock Zaključaj Selection Odabir +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Pravougaoni odabir + Touching Dodirnuti objekti + Fully Enclosed Potpuno obuhvaćeni + Drag Direction Smjer povlačenja All Artwork Above Sav crtež iznad Other Layers Ostali slojevi Unlock All Otključaj sve @@ -326,6 +332,12 @@ Apply Bullets Primijeni oznake Numbered List Numerisana lista Apply Numbers Primijeni brojeve + Bullets and Numbering… Oznake i numerisanje… + Numbers Brojevi + Bullet Character: Znak oznake: + Number Style: Stil broja: + Left Indent: Lijeva uvlaka: + First Line Indent: Uvlaka prvog reda: Convert to Text Pretvori u tekst Filled Dot Ispunjena tačka Dash Crtica @@ -454,6 +466,17 @@ Inflate Napuhni Select objects, then pick an object type to give them a 3D effect. Odaberite objekte, zatim odaberite vrstu objekta da im date 3D efekat. Extrude & Bevel… Istiskivanje i zakošenje… + 3D (Classic) 3D (klasično) + Extrude & Bevel (Classic)… Istiskivanje i zakošenje (klasično)… + Surface Površina + Wireframe Žičani model + No Shading Bez sjenčenja + Plastic Plastika + Highlight Intensity Intenzitet odsjaja + Highlight Size Veličina odsjaja + Blend Steps Koraci prelaza + Shading Color Boja sjenčenja + Draw Hidden Faces Crtaj skrivene strane Revolve… Rotaciono tijelo… Inflate… Napuhni… Materials… Materijali… @@ -665,6 +688,7 @@ Toolbars Trake s alatima Advanced Napredno Double Column Dvostruka kolona + Keep Tools Beside the Canvas Alati pored platna Actions Akcije Asset Export Izvoz resursa Attributes Atributi @@ -672,6 +696,11 @@ Color Boja Color Guide Vodič za boje Color Themes Teme boja + Color Wheel Kotač boja + Hue ring Prsten nijanse + Saturation and brightness Zasićenje i svjetlina + Color field Polje boje + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties CSS svojstva Document Info Informacije o dokumentu Flattener Preview Pregled spljoštavanja @@ -874,6 +903,9 @@ Stained Glass… Vitraž… Texturizer… Teksturizator… System Title Bar Sistemska naslovna traka + VectorCraft Title Bar on Every Desktop Naslovna traka VectorCrafta na svakoj radnoj površini + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Dok je varijabla okruženja VECTORCRAFT_TITLEBAR postavljena, ona bira naslovnu traku. + System Title Bar takes precedence over this option while it is on. Dok je uključena, Sistemska naslovna traka ima prednost pred ovom opcijom. Exit Izlaz Distort Izobliči Diffuse Glow… Difuzni sjaj… @@ -1237,7 +1269,7 @@ lighting Light Svjetlo X: X: Y: Y: Drag to float the toolbar Prevucite da odvojite traku s alatima - Drag to the window's left edge to dock Prevucite do lijeve ivice prozora za sidrenje + Drag to the window's left or right edge to dock Prevucite do lijeve ili desne ivice prozora za sidrenje Crystallize Kristalizuj Add Anchor Point Tool Alat za dodavanje sidrene tačke Anchor Point Tool Alat sidrena tačka @@ -1491,6 +1523,15 @@ digits Malayalam Malajalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Postavke za Tate-chu-yoko… Tate-chu-yoko Settings Postavke za Tate-chu-yoko + Composite Fonts… Kompozitni fontovi… + Composite Fonts Kompozitni fontovi + Export Composite Fonts Izvezi kompozitne fontove + Import Composite Fonts Uvezi kompozitne fontove + Delete Font Izbriši font + Kanji Kanji + Kana Kana + Numerals Brojevi + Scale at Center Skaliraj od centra Up/Down: Gore/dolje: Left/Right: Lijevo/desno: Underline Podvučeno @@ -1869,6 +1910,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 @@ -2713,6 +2758,10 @@ digits Malayalam Malajalam < 1 sec < 1 sek. [Unnamed] [Bez naziva] Amount: Količina: + Anti-aliasing Method Metoda zaglađivanja ivica +antiAlias Sharp Oštro + Crisp Jasno + Strong Snažno Anti-aliasing: Zaglađivanje ivica: Artboard: Radna ploča: Color Table Tabela boja @@ -3142,6 +3191,9 @@ theme Appearance Izgled Asset Export Settings Postavke izvoza resursa Assets Resursi Export Assets Izvezi resurse + Export 3D Object Izvezi 3D objekat + 3D Export Format Format 3D izvoza + Export 3D Assets Izvezi 3D resurse Remove Asset Ukloni resurs Rename Asset Preimenuj resurs Fill Rule Pravilo ispune @@ -3577,6 +3629,7 @@ theme Appearance Izgled Feet Stope Files Datoteke Fill New Type Objects With Placeholder Text Ispuni nove tekstualne objekte tekstom čuvara mjesta + Start Lists From Typed Markers Započni liste iz otkucanih oznaka Folder Folder Font Preview Size Veličina pregleda fonta Get Preferences Preuzmi postavke @@ -3699,6 +3752,7 @@ theme Appearance Izgled Yards Jardi Zoom to Selection Zumiraj na odabir Zoom with Mouse Wheel Zumiraj točkićem miša + Scroll Sideways with Cmd/Ctrl + Wheel Pomjeraj vodoravno uz Cmd/Ctrl + točkić miša Scrub Numeric Fields by Dragging Mijenjaj numerička polja prevlačenjem Linux Display Server Linux server prikaza Print Preview Pregled štampanja @@ -3985,6 +4039,7 @@ effect Regular Obično Find in Folder… Pronađi u folderu… Replace With Font Zamijeni fontom Replace With Font From Zamijeni fontom iz + Find In Traži u System Sistem — substituted by {style} — zamijenjeno stilom {style} — {n} characters from another font — {n} znakova iz drugog fonta @@ -4229,7 +4284,7 @@ imageTrace Outline Obris @plural {n} stroke|{n} strokes {n} potez|{n} poteza|{n} poteza @msg no library `{id}` nema biblioteke `{id}` @msg select some type first prvo odaberite tekst -@msg kerning must be a number, "auto", or "optical" Podrezivanje mora biti broj, „auto” ili „optical” +@msg kerning must be a number, "auto", or "optical" kerning mora biti broj, „auto” ili „optical” @msg the selection has no gradient fill odabir nema ispunu gradijentom @msg the selection has no gradient stroke odabir nema potez gradijentom @msg the selection has no pattern fill odabir nema ispunu uzorkom @@ -4329,6 +4384,7 @@ imageTrace Outline Obris @msg Split Into Grid: select paths large enough for the grid Podijeli u mrežu: odaberite putanje dovoljno velike za mrežu @msg Targa files are RGB: colorModel rgb Targa datoteke su RGB: colorModel rgb @msg Text Wrap: select a wrap object Omotavanje teksta: odaberite objekat omotavanja +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` mora biti "none", "sharp", "crisp" ili "strong" @msg `aspect` must be a number (%) `aspect` mora biti broj (%) @msg `at` must be [x, y] `at` mora biti [x, y] @msg `color` must be a colour `color` mora biti boja @@ -4440,7 +4496,7 @@ imageTrace Outline Obris @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art ili type @msg artboard {0} does not exist radna ploča {0} ne postoji @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard u ovoj skali crtež je {w} × {h} pt, više nego što platno može primiti: odaberite manju skalu ili ga uklopite u radnu ploču -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` osa mora biti "vertical", "horizontal" ili ugao u stepenima, a ne `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis mora biti "vertical", "horizontal" ili ugao u stepenima, a ne `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" pozadina {other}: transparent, white, black ili boja poput "#ff8800" @msg bad color {v} loša boja {v} @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio mora biti broj od 0,1 do 10 @@ -4454,7 +4510,7 @@ imageTrace Outline Obris @msg bad shortcut loša prečica @msg bad stream length loša dužina toka @msg bad trailer loš trailer -@msg balance must be between 0 and 100 balans mora biti između 0 i 100 +@msg balance must be between 0 and 100 balance mora biti između 0 i 100 @msg batch step {_1} (`{id}`) failed: {e} korak paketa {_1} (`{id}`) nije uspio: {e} @msg brightness must be dark|mediumDark|mediumLight|light brightness mora biti dark|mediumDark|mediumLight|light @msg can't encode the document: {0} nije moguće kodirati dokument: {0} @@ -4605,6 +4661,8 @@ imageTrace Outline Obris @msg no such action set nema takvog skupa akcija @msg the action sets changed; nothing was deleted skupovi akcija su se promijenili; ništa nije izbrisano @msg no action set to save nema skupa akcija za čuvanje +@msg the action set file can't be read in full datoteka skupa akcija ne može se pročitati u cijelosti +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Učitano „{name}”: {n} korak je izostavljen|Učitano „{name}”: {n} koraka su izostavljena|Učitano „{name}”: {n} koraka je izostavljeno @msg not an action set file nije datoteka skupa akcija @msg the action set file is larger than 8 MiB datoteka skupa akcija je veća od 8 MiB @msg the action set file holds more than 1,000 actions datoteka skupa akcija sadrži više od 1.000 akcija @@ -4679,7 +4737,7 @@ imageTrace Outline Obris @msg replace: the object to replace was changed or deleted replace: objekat za zamjenu je promijenjen ili izbrisan @msg pattern missing uzorak nedostaje @msg patterns nested too deeply are filled with mid-grey previše duboko ugniježđeni uzorci ispunjavaju se srednje sivom -@msg percent must be above 0 and at most 6400, not {p} postotak mora biti iznad 0 i najviše 6400, a ne {p} +@msg percent must be above 0 and at most 6400, not {p} percent mora biti iznad 0 i najviše 6400, a ne {p} @msg pick a colour, gradient or pattern to save as a swatch odaberite boju, gradijent ili uzorak za čuvanje kao uzorak boje @msg plane must be left, right or ground plane mora biti left, right ili ground @msg plug-in ABI: {0} ABI dodatka: {0} @@ -4700,8 +4758,8 @@ imageTrace Outline Obris @msg result would exceed the canvas (coordinates out of range) rezultat bi premašio platno (koordinate izvan raspona) @msg objects can't be nested more than 32 deep objekti ne mogu biti ugniježđeni dublje od 32 nivoa @msg the SVG's elements are nested more than {0} deep elementi SVG-a ugniježđeni su dublje od {0} nivoa -@msg scale must be a number from {_1} to {_2}, not {_3} skala mora biti broj od {_1} do {_2}, a ne {_3} -@msg scale must be a number from {_1} to {_2}, not {s} skala mora biti broj od {_1} do {_2}, a ne {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale mora biti broj od {_1} do {_2}, a ne {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale mora biti broj od {_1} do {_2}, a ne {s} @msg select (or target) objects whose appearance isn't basic odaberite (ili ciljajte) objekte čiji izgled nije osnovni @msg select a Live Paint group odaberite grupu živog bojenja @msg select a blend odaberite stapanje @@ -4956,10 +5014,14 @@ imageTrace Outline Obris @msg its objects are nested too deeply njeni objekti su previše duboko ugniježđeni @msg its section ending with `%{end}` doesn't end njen odjeljak koji završava s `%{end}` ne završava @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) četkice koje VectorCraft još ne može pročitati izostavljene su iz panela Četkice ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekti s više ispuna ili poteza, efekata ili četkica stigli su kao njihov iscrtani izgled ({_1}): njihov izgled se još ne može uređivati +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekti s više ispuna ili poteza, poteza promjenljive širine, efekata ili četkica stigli su kao njihov iscrtani izgled ({_1}): njihov izgled se još ne može uređivati +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding naziv `{name}` nije u UTF-8 i pročitan je kao {label}: ako izgleda pogrešno, otvorite datoteku s drugim legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding naziv `{name}` nije u UTF-8, a dijelovi nisu ispravni ni u {label}: otvorite datoteku s drugim legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) još naziva koji nisu u UTF-8 pročitano je kao {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradijent „{name}” nije definisan u datoteci: umjesto njega koristi se obična boja @msg an image VectorCraft can't read was left out izostavljena je slika koju VectorCraft ne može pročitati @msg an image whose data is cut short was left out izostavljena je slika čiji su podaci skraćeni +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers ugrađena slika od {_1} × {_2} piksela smanjena je na {_3} × {_4} piksela kako bi se zadržali slojevi @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel povezane slike prikazuju pregled niske rezolucije sačuvan u datoteci dok se njihove datoteke ne pronađu: ponovo ih povežite u panelu Veze @msg linked type areas past VectorCraft's limits were left out povezana područja teksta izvan ograničenja programa VectorCraft su izostavljena @msg type that changes writing direction inside a text object opens in the direction of its first character tekst koji mijenja smjer pisanja unutar tekstualnog objekta otvara se u smjeru svog prvog znaka @@ -4995,6 +5057,7 @@ imageTrace Outline Obris @msg object ids are non-negative integers, not {v} ID-ovi objekata su nenegativni cijeli brojevi, a ne {v} @msg {key} must be an array of non-negative integer object ids {key} mora biti niz nenegativnih cjelobrojnih ID-ova objekata @msg invalid object id {value}: expected a non-negative integer nevažeći ID objekta {value}: očekuje se nenegativan cijeli broj +@msg `{key}` entry {index} is {value}, not a non-negative integer object id stavka {index} u `{key}` je {value}, a ne ID objekta (nenegativan cijeli broj) @msg no such object id {value} nema objekta s ID-om {value} @msg id {value} is a layer, not an object id {value} je sloj, a ne objekat @msg id {value} is a layer, not an object; move layers with layer.move id {value} je sloj, a ne objekat; slojeve pomjerajte s layer.move @@ -5021,6 +5084,9 @@ imageTrace Outline Obris @msg the Pixar file's image data is damaged slikovni podaci Pixar datoteke su oštećeni @msg the JPEG 2000 file can't be read datoteku JPEG 2000 nije moguće pročitati @msg the JPEG 2000 file has an unsupported color space or JPX content datoteka JPEG 2000 ima nepodržan prostor boja ili JPX sadržaj +@msg the HEIC or HEIF file can't be read datoteku HEIC ili HEIF nije moguće pročitati +@msg the HEIC or HEIF file holds a kind of image that can't be read datoteka HEIC ili HEIF sadrži vrstu slike koju nije moguće pročitati +@msg HEIC and HEIF images can't be opened in this build HEIC i HEIF slike nije moguće otvoriti u ovoj verziji programa @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD slika je prevelika za čitanje ({_1} × {_2} piksela) @msg zipped 32-bit PSD image data with prediction can't be read zipovani 32-bitni PSD podaci slike s predviđanjem ne mogu se pročitati @msg {depth}-bit PSD files in this colour mode can't be read {depth}-bitne PSD datoteke u ovom načinu boja ne mogu se pročitati @@ -5029,7 +5095,7 @@ imageTrace Outline Obris @msg the PSD file's image data can't be read: {e} podaci slike PSD datoteke se ne mogu pročitati: {e} @msg Imported library {name} Uvezena biblioteka {name} @msg Invalid panel layout; restored the default dock Nevažeći raspored panela; vraćeno je zadano sidrište -@msg action is required akcija je obavezna +@msg action is required action je obavezan @msg anchor must name a registered panel anchor mora navesti registrovani panel @msg before must name a panel before mora navesti panel @msg docking action contains an unknown panel or an excessive split path akcija sidrenja sadrži nepoznat panel ili prekomjernu putanju podjele @@ -5726,11 +5792,14 @@ axis Y Axis Y osa @msg Running “{name}”… Pokretanje „{name}”… @msg a live effect runs while the document is drawn, so it can't be `heavy` živi efekat radi dok se dokument iscrtava, pa ne može biti `heavy` @msg an exporter always gets an export's budget: leave `budget` out izvoznik uvijek dobija budžet izvoza: izostavite `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}” traži veliki budžet (svako pokretanje do {minutes} minuta i {times} puta više uobičajenih instrukcija): instalirajte ga s `allowHeavy: true` kada korisnik pristane +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}” traži veliki budžet (svako pokretanje do {minutes} minuta i {times} puta više uobičajenih instrukcija): osoba ga mora odobriti u desktop aplikaciji, ili proslijedite `allowHeavy: true` u sesiji pokrenutoj s --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Instalirati „{name}”, koji traži ove dozvole? ID: {id} ID: {id} Version: {version} Verzija: {version} + Install “{name}” in place of the plug-in with the same ID? Instalirati „{name}” umjesto dodatka s istim ID-om? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ova datoteka nije dodatak ranije instaliran s ovim ID-om. Dobija postavke i predmemoriju sačuvane za taj ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}” je drugi modul od dodatka s ID-om {_1} i dobio bi postavke i predmemoriju sačuvane za njega: instalirajte ga s `replace: true` kada korisnik pristane File SHA-256: {hash} SHA-256 datoteke: {hash} Read the objects it is given Čitati objekte koje dobije Change, add and delete objects in the document Mijenjati, dodavati i brisati objekte u dokumentu @@ -5738,10 +5807,13 @@ axis Y Axis Y osa Change how the objects its effect is applied to are drawn Mijenjati kako se crtaju objekti na koje je primijenjen njegov efekt Write the document as a file in its own format Zapisati dokument kao datoteku u vlastitom formatu Show its progress, see Cancel and write log lines Prikazati napredak, prepoznati otkazivanje i zapisivati redove dnevnika -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} dozvola `progress` zahtijeva nivo API-ja dodataka {PROGRESS_API}: navedite `api` {PROGRESS_API} + Keep settings of its own Čuvati vlastite postavke + Keep a cache of its own, which you can clear Čuvati vlastitu predmemoriju koju možete očistiti + Clear Plug-in Cache Očisti predmemoriju dodatka +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} dozvola `{permission}` zahtijeva nivo API-ja dodataka {PROGRESS_API}: navedite `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} modul uvozi `env.{name}` s drugim tipom od domaćinovog: domaćinov tip je {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modul uvozi `env.{name}`, koji dodatak smije koristiti samo na nivou API-ja dodataka {PROGRESS_API} (`api` {PROGRESS_API}) i s dozvolom `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modul uvozi `{from}.{name}`, koji domaćin ne nudi: dodatak smije uvoziti samo `env.vc_progress`, `env.vc_cancelled` i `env.vc_log` (nivo API-ja dodataka 2), a nikakav WASI, datoteke ni mrežu +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` modul uvozi `env.{name}`, koji dodatak smije koristiti samo na nivou API-ja dodataka {PROGRESS_API} (`api` {PROGRESS_API}) i s dozvolom `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network modul uvozi `{from}.{name}`, koji domaćin ne nudi: dodatak smije uvoziti samo funkcije `env.vc_*` koje navodi docs/plugins.md (nivo API-ja dodataka 2), a nikakav WASI, datoteke ni mrežu @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` je {_1}; ovaj domaćin podržava nivoe API-ja dodataka od 1 do {API_LEVEL} @msg `grant` must be a list of permission names `grant` mora biti lista naziva dozvola @msg `grant` names an unknown permission `{shown}` `grant` navodi nepoznatu dozvolu `{shown}` @@ -5751,7 +5823,9 @@ axis Y Axis Y osa @msg permission `{shown}` is listed twice dozvola `{shown}` navedena je dvaput @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it dozvola `{shown}` rezervisana je za kasniji API dodataka; ovaj domaćin je ne dodjeljuje @msg unknown permission `{shown}` nepoznata dozvola `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}” traži dozvole {asked}: instalirajte ga s `grant` koji ih navodi kada korisnik pristane +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants „{name}” traži dozvole {asked}: osoba ih mora odobriti u desktop aplikaciji, ili proslijedite `grant` u sesiji pokrenutoj s --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` i `allowHeavy` važe samo u sesiji pokrenutoj s --allow-plugin-grants: odobrite dodatak u desktop aplikaciji +@msg Only the person at the keyboard can approve installing a plug-in Instalaciju dodatka može odobriti samo osoba za tastaturom @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}” nije dobio `{p}`, što je potrebno njegovom izlazu: ništa nije promijenjeno @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modul nema SHA-256 naveden u `hash`: ništa nije instalirano New Set… Novi skup… @@ -5829,7 +5903,7 @@ axis Y Axis Y osa @msg the document has no symbols to save dokument nema simbola za čuvanje @msg Trap: select two or more objects filled with solid colours Zamka: odaberite dva ili više objekata ispunjenih punim bojama @msg Trap: no edges of different colours meet among the selected objects Zamka: među odabranim objektima ne susreću se ivice različitih boja -@msg thickness must be from 0.01 to 5000 pt debljina mora biti od 0,01 do 5000 pt +@msg thickness must be from 0.01 to 5000 pt thickness mora biti od 0,01 do 5000 pt @msg heightWidth must be from 1 to 1000 % heightWidth mora biti od 1 do 1000 % @msg tintReduction must be from 0 to 100 % tintReduction mora biti od 0 do 100 % Rectangular Grid Tool Options Opcije alata pravougaona mreža @@ -5837,7 +5911,7 @@ axis Y Axis Y osa Fill Line Ispuni liniju Pathfinder Options Opcije Pathfindera Divide and Outline Will Remove Unpainted Artwork Podijeli i Obris uklanjaju neobojenu grafiku -@msg precision must be from 0.001 to 10 pt preciznost mora biti od 0,001 do 10 pt +@msg precision must be from 0.001 to 10 pt precision mora biti od 0,001 do 10 pt @msg `pathfinder` must be an object `pathfinder` mora biti objekat Add to Brushes Dodaj u četkice Brush Library Biblioteka četkica @@ -6111,6 +6185,8 @@ shape Open Otvoren File + Data Set Name Datoteka + naziv skupa podataka Data Set Name Naziv skupa podataka @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` mora biti `fileNumber`, `fileDataSetName` ili `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Svaki skup podataka se sačuva preko iste datoteke, u kojoj ostaje samo posljednji skup podataka. +@msg save the document with Save As before Save and Close sačuvajte dokument pomoću „Sačuvaj kao“ prije „Sačuvaj i zatvori“ @msg no free data set file name in destination folder Nema slobodnog naziva datoteke skupa podataka u odredišnoj mapi # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6265,9 +6341,20 @@ shape Open Otvoren Reset Symbol Instance Vrati instancu simbola na početno @msg type must be static or dynamic, not {v} type mora biti static ili dynamic, a ne {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` je statički simbol: prvo postavite vrstu simbola na Dinamički +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Putanji, složenoj putanji ili tekstu odabranom u instanci simbola mogu se mijenjati samo pune boje ispune i poteza @msg path must be a list of child indices path mora biti lista indeksa podređenih objekata @msg no object at path {shown} in the symbol's art nema objekta na putanji {shown} u grafici simbola @msg give fill, stroke, opacity and/or blend navedite fill, stroke, opacity i/ili blend +@msg {key} cannot be set on a guide {key} se ne može postaviti na vodilicu +@msg {key} cannot be set on a blend {key} se ne može postaviti na stapanje +@msg {key} cannot be set on an envelope {key} se ne može postaviti na omotnicu +@msg {key} cannot be set on a gradient mesh {key} se ne može postaviti na gradijentnu mrežu +@msg {key} cannot be set on a Repeat {key} se ne može postaviti na ponavljanje +@msg {key} cannot be set on a placed document {key} se ne može postaviti na postavljeni dokument +@msg {key} cannot be set on an Intertwine group {key} se ne može postaviti na Intertwine grupu +@msg stroke cannot be set on a symbol instance stroke se ne može postaviti na instancu simbola +@msg fill on a symbol instance must be a solid color or none fill instance simbola mora biti puna boja ili none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} se ne može postaviti na sliku bez efekta „Pretvori u oblik“ ili „Objekat u obrisu“ @msg pass newName, registration, exportType, nineSlice and/or type navedite newName, registration, exportType, nineSlice i/ili type Text Flow Tok teksta By Rows Po redovima @@ -6297,6 +6384,7 @@ shape Open Otvoren Create Swatch Legend Kreiraj legendu uzoraka boja Create Swatch Legend… Kreiraj legendu uzoraka boja… HEX HEX + Help Bar Traka pomoći # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Alati za tekst Text Tweaker… Podešavanje teksta… @@ -6363,3 +6451,30 @@ 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 + +# Safe Mode + Safe Mode… Sigurni način rada… + Safe Mode Sigurni način rada + Help › Safe Mode… Pomoć › Sigurni način rada… + Enable on Re-launch Omogući pri sljedećem pokretanju + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Posljednje pokretanje programa VectorCraft zaustavilo se dok se učitavao dodatak {name}. Možda on nije uzrok zaustavljanja. + Disable and Continue Onemogući i nastavi + Start Normally Pokreni normalno + Start in Safe Mode Pokreni u sigurnom načinu rada + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft je u sigurnom načinu rada: dodaci se ne učitavaju niti pokreću dok se program ne pokrene normalno. + Disabled plug-ins Onemogućeni dodaci + No plug-ins are disabled. Nijedan dodatak nije onemogućen. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Sigurni način rada je uključen: dodaci se ne učitavaju niti pokreću dok se VectorCraft ne pokrene normalno +@msg give `id` or `file` (see plugin.safeMode) navedite `id` ili `file` (vidi plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) taj dodatak nije onemogućen (vidi plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch Onemogućeni dodaci nisu mogli biti sačuvani za sljedeće pokretanje +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Proširi izgled: efekat dodatka u odabiru ne može se proširiti u sigurnom načinu rada + +# Align to Pixel Grid + Align to Pixel Grid Poravnaj s mrežom piksela +@msg side is left or right, not `{s}` side je left ili right, a ne `{s}` diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index ea246b35c..2fec000ec 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -299,6 +299,10 @@ Anti-alias Rasters Antialiàsing de ràsters Anti-aliased Artwork Art amb antialiàsing Anti-aliasing: Antialiàsing: + Anti-aliasing Method Mètode d'antialiàsing +antiAlias Sharp Nítid + Crisp Definit + Strong Intens Any except extracting pages Qualsevol excepte extreure pàgines Any Kind Qualsevol tipus Appearance Aparença @@ -551,8 +555,10 @@ Built-in presets don't change: New… starts an editable copy. Els preajustos integrats no canvien: Nou… inicia una còpia editable. Bulge… Abombament… Bullet Punt de llista + Bullet Character: Caràcter del punt de llista: Bullets Punts de llista Bullets and Numbering Punts i numeració + Bullets and Numbering… Punts i numeració… Burasagari Burasagari Burlap Arpillera Business Card 3.5 × 2 in Targeta de visita 3,5 × 2 in @@ -760,6 +766,11 @@ Color Table ({n} colors) Taula de colors ({n} colors) Color Theme Tema de colors Color Themes Temes de colors + Color Wheel Roda de colors + Hue ring Anell de to + Saturation and brightness Saturació i lluentor + Color field Camp de color + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Tipus de color: Color wheel Roda de color Color: Color: @@ -1206,6 +1217,7 @@ Double Bar Barra doble Double Click To Isolate Doble clic per aïllar Double Column Columna doble + Keep Tools Beside the Canvas Eines al costat del llenç Double Left Quotation Marks Cometes dobles esquerres Double Quotes: Cometes dobles: Double Right Quotation Marks Cometes dobles dretes @@ -1241,7 +1253,7 @@ Drag to float as a toolbar Arrossega per surar com a barra d’eines Drag to float the toolbar Arrossega per fer surar la barra d’eines Drag to move Arrossega per moure - Drag to the window's left edge to dock Arrossega fins a la vora esquerra de la finestra per acoblar + Drag to the window's left or right edge to dock Arrossega fins a la vora esquerra o dreta de la finestra per acoblar Drag values or labels. Double-click a value to type. Arrossega els valors o les etiquetes. Fes doble clic en un valor per escriure’l. Draw Dibuixa Draw Behind Dibuixa al darrere @@ -1464,6 +1476,14 @@ Export As SVG… Exporta com a SVG… Export Asset Exporta el recurs Export Assets Exporta els recursos + Export 3D Object Exporta l’objecte 3D + 3D Export Format Format d’exportació 3D + Export 3D Assets Exporta els recursos 3D +@msg {what} can't be exported in 3D: {e} no es pot exportar {what} en 3D: {e} +@msg asset {id} isn't a 3D asset el recurs {id} no és un recurs 3D +@msg asset {id} is a 3D asset: assets.export3D writes it el recurs {id} és un recurs 3D: l’escriu assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) encara no hi ha cap recurs 3D: recolliu primer un objecte amb un efecte 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) seleccioneu un objecte amb un efecte 3D (o indiqueu-ne els id) Export As… Exporta com a… Export CSS Exporta CSS Export CSS… Exporta CSS… @@ -1503,6 +1523,17 @@ Exporting {} S’està exportant {} Export… Exporta… Extrude & Bevel… Extrudeix i bisella… + 3D (Classic) 3D (clàssic) + Extrude & Bevel (Classic)… Extrudeix i bisella (clàssic)… + Surface Superfície + Wireframe Filferro + No Shading Sense ombra + Plastic Plàstic + Highlight Intensity Intensitat de la brillantor + Highlight Size Mida de la brillantor + Blend Steps Passos de fusió + Shading Color Color de l’ombra + Draw Hidden Faces Dibuixa les cares ocultes Eyedropper Comptagotes Eyedropper Applies: El comptagotes aplica: Eyedropper Options Opcions del comptagotes @@ -1555,6 +1586,7 @@ digits Malayalam Malaiàlam Fill Line Línia de farciment Fill new strokes Omple els traços nous Fill New Type Objects With Placeholder Text Omple els objectes de text nous amb text de mostra + Start Lists From Typed Markers Comença llistes a partir dels marcadors escrits Fill Rule Regla de farciment Fill with Placeholder Text Omple amb text de mostra Fill: Farciment: @@ -1580,12 +1612,14 @@ digits Malayalam Malaiàlam Find Linked Files in Folder… Cerca fitxers enllaçats en una carpeta… Find Next Cerca el següent Find Objects Cerca objectes + Compare with Image Compara amb una imatge Fine Dots Punts fins Fine noise hides banding in the gradient fill El soroll fi amaga l’efecte de bandes del farciment de degradat First Primer First Artboard Primer tauler First Baseline Primera línia de base First commit Primera confirmació + First Line Indent: Sagnia de primera línia: First Page Primera pàgina First-line Left Indent Sagnia esquerra de primera línia Fisheye… Ull de peix… @@ -2107,6 +2141,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 @@ -2159,6 +2194,7 @@ digits Malayalam Malaiàlam Left Grid: Quadrícula esquerra: Left in Front Esquerra al davant Left Indent Sagnia esquerra + Left Indent: Sagnia esquerra: Left Plane Pla esquerre Left to Right D’esquerra a dreta Left-Justified Tab Tabulació alineada a l’esquerra @@ -2686,8 +2722,10 @@ digits Malayalam Malaiàlam Number of Decimals: Nombre de decimals: Number of Recent Files to Display Nombre de fitxers recents que es mostraran Number of Recent Fonts Nombre de tipus de lletra recents + Number Style: Estil de número: Number: Número: Numbered List Llista numerada + Numbers Números Numbers Without Units Are Points Els nombres sense unitats són punts Object Objecte Object Highlighting Ressaltat d’objectes @@ -3174,6 +3212,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} @@ -3215,6 +3256,7 @@ digits Malayalam Malaiàlam Rating: Valoració: Ratio Proporció Rays Raigs + Read Action Set File Llegeix el fitxer de conjunt d’accions Read Shortcut Set File Llegeix el fitxer de conjunt de drets de teclat Reads or sets environment variables Llegeix o defineix variables d’entorn Ready A punt @@ -3394,6 +3436,7 @@ digits Malayalam Malaiàlam Reset Transformation Restableix la transformació Replace With Font Reemplaça per un tipus de lletra Replace With Font From Reemplaça per un tipus de lletra de + Find In Cerca a System Sistema Replace “{name}”, the file this document was opened from? Voleu reemplaçar “{name}”, el fitxer amb què es va obrir aquest document? Replaces the saved preset of that name. Reemplaça el preajust desat amb aquest nom. @@ -3727,6 +3770,7 @@ digits Malayalam Malaiàlam Scribble… Garabateig… Script / Brush Cal·ligràfica / Pinzell Scripts Scripts + Scroll Sideways with Cmd/Ctrl + Wheel Desplaça horitzontalment amb Cmd/Ctrl + roda del ratolí Scrub Numeric Fields by Dragging Canvia els camps numèrics arrossegant Search Cerca Search All Cerca a tot @@ -3795,6 +3839,11 @@ digits Malayalam Malaiàlam Selected Swatches Only (0) Només les mostres seleccionades (0) Selected Symbols Only Només els símbols seleccionats Selection Selecció +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Selecció rectangular + Touching Objectes tocats + Fully Enclosed Totalment inclosos + Drag Direction Direcció de l’arrossegament Selection & Anchor Display Selecció i visualització d’ancoratges Selection Only Només la selecció Selection Tool Eina de selecció @@ -4273,6 +4322,9 @@ digits Malayalam Malaiàlam Syntax newer than ES3 Sintaxi més nova que ES3 System Layout Disposició del sistema System Title Bar Barra de títol del sistema + VectorCraft Title Bar on Every Desktop Barra de títol de VectorCraft a tots els escriptoris + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Mentre està definida, la variable d’entorn VECTORCRAFT_TITLEBAR determina la barra de títol. + System Title Bar takes precedence over this option while it is on. Mentre està activada, Barra de títol del sistema té prioritat sobre aquesta opció. Tab Tabulació Tab Stops Aturades de tabulació Table Taula @@ -4296,6 +4348,15 @@ digits Malayalam Malaiàlam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Configuració de Tate-chu-yoko… Tate-chu-yoko Settings Configuració de Tate-chu-yoko + Composite Fonts… Tipus de lletra compostos… + Composite Fonts Tipus de lletra compostos + Export Composite Fonts Exporta tipus de lletra compostos + Import Composite Fonts Importa tipus de lletra compostos + Delete Font Suprimeix el tipus de lletra + Kanji Kanji + Kana Kana + Numerals Xifres + Scale at Center Escala des del centre Up/Down: Amunt/avall: Left/Right: Esquerra/dreta: Technical Drawing Dibuix tècnic @@ -4864,6 +4925,7 @@ digits Malayalam Malaiàlam 3d Solid Sòlid 3d Space Espai @msg "{0}" is not a colour: write it as #rrggbb "{0}" no és un color: escriviu-lo com a #rrggbb +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` ha de ser "none", "sharp", "crisp" o "strong" @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all o un interval com ara 1, 3-5, no {v} @msg `aspect` must be a number (%) `aspect` ha de ser un nombre (%) @msg `at` must be [x, y] `at` ha de ser [x, y] @@ -4998,7 +5060,7 @@ digits Malayalam Malaiàlam @msg a value has the wrong type for the operator un valor té el tipus incorrecte per a l’operador @msg a value is outside the range the operator takes un valor està fora de l’interval que accepta l’operador @msg a {width}×{height} screenshot's byte count overflows el recompte de bytes d’una captura de pantalla de {width}×{height} es desborda -@msg action is required cal una acció +@msg action is required cal action @msg add a format to export afegiu un format per exportar @msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 escriu fitxers .ai heretats, no plantilles .ait @msg already editing a pattern ja s’està editant un motiu @@ -5006,15 +5068,21 @@ digits Malayalam Malaiàlam @msg an image has no pixels and was left out una imatge no té píxels i s’ha omès @msg an image of {w} × {h} pixels is shown at reduced size una imatge de {w} × {h} píxels es mostra a mida reduïda @msg an image of {w} × {h} pixels is too large to draw una imatge de {w} × {h} píxels és massa gran per dibuixar-la +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: no és una imatge PNG, JPEG, WebP, TIFF ni BMP +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ la imatge fa {iw} × {ih} píxels i el tauler a aquesta amplada fa {rw} × {rh} píxels: les proporcions són diferents +@msg `threshold` must be a number from 0 to 1 `threshold` ha de ser un nombre de 0 a 1 +@msg background {v}: a color such as "#808080" fons {v}: un color com "#808080" +@msg comparing with an image file isn't available in the browser la comparació amb un fitxer d’imatge no està disponible al navegador @msg an image VectorCraft can't read was left out s’ha omès una imatge que VectorCraft no pot llegir @msg graphs come in as plain art, without their data or settings els gràfics s’importen com a il·lustració normal, sense les seves dades ni la seva configuració @msg an image whose data is cut short was left out s’ha omès una imatge les dades de la qual estan tallades +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers una imatge incrustada de {_1} × {_2} píxels s’ha reduït a {_3} × {_4} píxels per conservar les capes @msg an object can't be read no es pot llegir un objecte @msg an object in perspective would leave the edited grid un objecte en perspectiva sortiria de la quadrícula editada @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Un objecte admet un efecte 3D. Editeu les opcions de l’efecte existent o suprimiu-lo primer. -@msg anchor must name a registered panel l’àncora ha de nomenar un panell registrat +@msg anchor must name a registered panel anchor ha de nomenar un panell registrat @msg Angles require two straight segments Els angles requereixen dos segments rectes -@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: cap, art o text +@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art o type @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise el text d’àrea en marcs massa baixos per a una línia del tipus de lletra que mostren ha rebut marcs més alts ({_1}): d’altra manera el seu text no es veuria @msg artboard {0} does not exist el tauler {0} no existeix @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard a aquesta escala el dibuix fa {w} × {h} pt, més del que admet el llenç: trieu una escala més petita o ajusteu-lo al tauler @@ -5029,7 +5097,7 @@ digits Malayalam Malaiàlam @msg automation path rejected: parent traversal below a folder that doesn't exist: {path} camí d’automatització rebutjat: recorregut ascendent per sota d’una carpeta que no existeix: {path} @msg automation path rejected: path is empty camí d’automatització rebutjat: el camí és buit @msg automation path rejected: reserved device names are not allowed: {path} camí d’automatització rebutjat: no es permeten noms de dispositiu reservats: {path} -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` l’eix ha de ser "vertical", "horizontal" o un angle en graus, no `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis ha de ser "vertical", "horizontal" o un angle en graus, no `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" fons {other}: transparent, blanc, negre o un color com "#ff8800" @msg bad color {v} color incorrecte {v} @msg bad colour `{hex}` (#rrggbb) color incorrecte `{hex}` (#rrggbb) @@ -5037,7 +5105,7 @@ digits Malayalam Malaiàlam @msg bad shortcut drecera incorrecta @msg bad stream length longitud de flux incorrecta @msg bad trailer tràiler incorrecte -@msg balance must be between 0 and 100 l’equilibri ha d’estar entre 0 i 100 +@msg balance must be between 0 and 100 balance ha d’estar entre 0 i 100 @msg batch step {_1} (`{id}`) failed: {e} el pas de lot {_1} (`{id}`) ha fallat: {e} @msg Batch: {count} processed Lot: {count} processats @msg Batch: {count} processed, {failed} failed Lot: {count} processats, {failed} fallits @@ -5045,7 +5113,7 @@ digits Malayalam Malaiàlam @msg below and above must name a panel floating in another group below i above han de nomenar un panell flotant en un altre grup @msg Blend colors: select at least three objects with filled colours Fusiona colors: seleccioneu com a mínim tres objectes amb colors de farciment @msg BMP files are RGB or grayscale: colorModel rgb or gray Els fitxers BMP són RGB o escala de grisos: colorModel rgb o gray -@msg brightness must be dark|mediumDark|mediumLight|light la brillantor ha de ser dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light brightness ha de ser dark|mediumDark|mediumLight|light @msg brushes move only within their type els pinzells només es mouen dins del seu tipus @msg can't encode the document: {0} no es pot codificar el document: {0} @msg can't parse shortcut `{c}` no es pot analitzar la drecera `{c}` @@ -5179,6 +5247,7 @@ digits Malayalam Malaiàlam @msg invalid image data for `{0}` dades d’imatge no vàlides per a `{0}` @msg invalid native panel set conjunt de panells natius no vàlid @msg invalid object id {value}: expected a non-negative integer id d’objecte {value} no vàlid: s’esperava un enter no negatiu +@msg `{key}` entry {index} is {value}, not a non-negative integer object id l’entrada {index} de `{key}` és {value}, no un id d’objecte (un enter no negatiu) @msg Invalid panel layout; restored the default dock Disposició de panells no vàlida; s’ha restaurat l’acoblament per defecte @msg invalid parameters for `{cmd}`: {msg} paràmetres no vàlids per a `{cmd}`: {msg} @msg invalid PDF setting: {0} configuració del PDF no vàlida: {0} @@ -5358,6 +5427,8 @@ digits Malayalam Malaiàlam @msg no such action set no existeix aquest conjunt d’accions @msg the action sets changed; nothing was deleted els conjunts d’accions han canviat; no s’ha suprimit res @msg no action set to save no hi ha cap conjunt d’accions per desar +@msg the action set file can't be read in full el fitxer de conjunt d’accions no es pot llegir sencer +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out S’ha carregat “{name}”: s’ha omès {n} pas|S’ha carregat “{name}”: s’han omès {n} passos @msg not an action set file no és un fitxer de conjunt d’accions @msg the action set file is larger than 8 MiB el fitxer de conjunt d’accions supera els 8 MiB @msg the action set file holds more than 1,000 actions el fitxer de conjunt d’accions conté més de 1.000 accions @@ -5436,7 +5507,10 @@ digits Malayalam Malaiàlam @msg objects can't be nested more than 32 deep els objectes no es poden niar més de 32 nivells @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path els objectes dins d’una fusió, un embolcall, una repetició o un grup Intertwine no es poden adjuntar a un traçat @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) els pinzells que VectorCraft encara no pot llegir s'han deixat fora del tauler Pinzells ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet els objectes amb diversos farciments o traços, efectes o pinzells han arribat amb el seu aspecte dibuixat ({_1}): la seva aparença encara no és editable +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet els objectes amb diversos farciments o traços, traços d’amplada variable, efectes o pinzells han arribat amb el seu aspecte dibuixat ({_1}): la seva aparença encara no és editable +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding el nom `{name}` no és UTF-8 i s’ha llegit com a {label}: si sembla incorrecte, obriu el fitxer amb un altre legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding el nom `{name}` no és UTF-8, i algunes parts tampoc no són {label} vàlid: obriu el fitxer amb un altre legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) s’han llegit com a {label} més noms que no són UTF-8 ({count}) @msg Offset Path: select paths Desplaça el traçat: selecciona traçats @msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Desplaça el traçat: el desplaçament no deixa res dels traçats seleccionats (una retracció més gran que les formes) @msg only a dock group above or below others collapses, not one beside another només es plega un grup d’acoblament situat a sobre o a sota d’altres, no un al costat d’un altre @@ -5455,7 +5529,7 @@ digits Malayalam Malaiàlam @msg page size {w} × {h} mida de pàgina {w} × {h} @msg page {0} does not exist (the PDF has {1}) la pàgina {0} no existeix (el PDF en té {1}) @msg panel group is empty el grup de panells és buit -@msg panel is not in a dock group el panell no és en un grup d’acoblament +@msg panel is not in a dock group panel no és en un grup d’acoblament @msg panel must name a registered panel panel ha de nomenar un panell registrat @msg part of the program isn't valid PostScript text una part del programa no és text PostScript vàlid @msg paste unavailable enganxament no disponible @@ -5467,7 +5541,7 @@ digits Malayalam Malaiàlam @msg PDF security settings are not an object la configuració de seguretat del PDF no és un objecte @msg PDF settings are not an object la configuració del PDF no és un objecte @msg PDF writer error: {0} error de l’escriptor PDF: {0} -@msg percent must be above 0 and at most 6400, not {p} el percentatge ha de ser superior a 0 i com a màxim 6400, no {p} +@msg percent must be above 0 and at most 6400, not {p} percent ha de ser superior a 0 i com a màxim 6400, no {p} @msg Persistent libraries not found: {files} No s’han trobat biblioteques persistents: {files} @msg pick a colour, gradient or pattern to save as a swatch tria un color, un degradat o un motiu per desar-lo com a mostra @msg Placing {_1} Col·locant {_1} @@ -5480,7 +5554,7 @@ digits Malayalam Malaiàlam @msg plug-in output: {0} sortida del complement: {0} @msg plug-in parameters: {0} paràmetres del complement: {0} @msg plug-in trapped: {0} el complement ha quedat atrapat: {0} -@msg precision must be from 0.001 to 10 pt la precisió ha de ser d’entre 0,001 i 10 pt +@msg precision must be from 0.001 to 10 pt precision ha de ser d’entre 0,001 i 10 pt @msg Preview in Browser needs the desktop app (the web downloads files instead) La previsualització al navegador necessita l’aplicació d’escriptori (a la web es baixen els fitxers) @msg print settings are not an object la configuració d’impressió no és un objecte @msg printing isn't available here la impressió no està disponible aquí @@ -5534,8 +5608,8 @@ digits Malayalam Malaiàlam @msg Saving S’està desant @msg Saving recovery data for {_1} S’estan desant les dades de recuperació de {_1} @msg Saving {_1} S’està desant {_1} -@msg scale must be a number from {_1} to {_2}, not {_3} l’escala ha de ser un número de {_1} a {_2}, no {_3} -@msg scale must be a number from {_1} to {_2}, not {s} l’escala ha de ser un número de {_1} a {_2}, no {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale ha de ser un número de {_1} a {_2}, no {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale ha de ser un número de {_1} a {_2}, no {s} @msg Scale must be drawing:real, for example 1:10 L’escala ha de ser drawing:real, per exemple 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 La relació d’escala ha d’estar entre 1:1000000 i 1000000:1 @msg Scale Text Attributes: selected type has no scale to bake Escala els atributs de text: el text seleccionat no té cap escala per aplicar @@ -5561,6 +5635,13 @@ digits Malayalam Malaiàlam @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 @@ -5655,6 +5736,11 @@ digits Malayalam Malaiàlam @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. La imatge fa {_1} × {_2} píxels, més de {LARGE_TRACE_SIDE} per costat: es traça, però més lentament. Un preajust de menys fidelitat o una còpia més petita de la imatge es traça més de pressa. @msg the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels) la imatge fa {width} × {height} píxels, més del que admet Image Trace ({max} megapíxels) @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). La imatge s’ha traçat a {_1} × {_2} píxels, reduïda des de {_3} × {_4} ({_5} megapíxels com a màxim). +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. El mode Logotip pla no fa servir keepColors: traça els colors indicats a logoColors o els colors que troba. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). No s’ha traçat res: cap dels colors que ha trobat el traçat coincideix amb keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. No s’ha traçat res: Ignora el color omet totes les àrees dels colors que llista keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. No s’ha traçat res: els colors que coincideixen amb keepColors no produeixen cap traçat. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: cap dels colors que ha trobat el traçat coincideix amb {c}, per tant no se’n traça res. @msg the image's pixels can't be read no es poden llegir els píxels de la imatge @msg the image's pixels don't match its size els píxels de la imatge no coincideixen amb la seva mida @msg The last start of VectorCraft ended on {name} before its window appeared, so it started on {summary}. To try that graphics processor again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set L’últim inici de VectorCraft va acabar a {name} abans que aparegués la seva finestra, així que s’ha iniciat amb {summary}. Per tornar a provar aquell processador gràfic, canvia Preferències › Rendiment › Processador gràfic o inicia amb WGPU_BACKEND definit @@ -5722,7 +5808,7 @@ digits Malayalam Malaiàlam @msg the {tool} tool has no options l’eina {tool} no té opcions @msg there are no objects to release no hi ha cap objecte per alliberar @msg there are no printer settings to open here aquí no hi ha cap configuració d’impressora per obrir -@msg thickness must be from 0.01 to 5000 pt el gruix ha de ser d’entre 0,01 i 5000 pt +@msg thickness must be from 0.01 to 5000 pt thickness ha de ser d’entre 0,01 i 5000 pt @msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) aquest fitxer s’ha desat sense compatibilitat amb PDF i les seves dades d’edició no es poden llegir ({why}) @msg the PDF part couldn't be read ({_1}): the file opened from its editing data no s’ha pogut llegir la part PDF ({_1}): el fitxer s’ha obert a partir de les seves dades d’edició @msg this file's PostScript couldn't be read ({why}): {what} no s’ha pogut llegir el PostScript d’aquest fitxer ({why}): {what} @@ -5841,11 +5927,14 @@ digits Malayalam Malaiàlam @msg {path}: {e} {path}: {e} @msg {status} ({e}) {status} ({e}) @msg {tool} isn't in a toolbar group of several tools {tool} no forma part d’un grup de diverses eines a la barra d’eines -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» demana un pressupost elevat (cada execució fins a {minutes} minuts i {times} vegades les instruccions habituals): instal·la’l amb `allowHeavy: true` quan l’usuari hi estigui d’acord +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants «{_1}» demana un pressupost elevat (cada execució fins a {minutes} minuts i {times} vegades les instruccions habituals): una persona l’ha d’aprovar a l’aplicació d’escriptori, o passa `allowHeavy: true` en una sessió iniciada amb --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Instal·la «{name}», que demana aquests permisos? ID: {id} ID: {id} Version: {version} Versió: {version} + Install “{name}” in place of the plug-in with the same ID? Instal·la «{name}» en lloc del complement amb el mateix ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Aquest fitxer no és el complement instal·lat abans amb aquest ID. Rep la configuració i la memòria cau desades per a aquest ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees «{name}» és un mòdul diferent del complement amb l’ID {_1} i rebria la configuració i la memòria cau desades per a aquest: instal·la’l amb `replace: true` quan l’usuari hi estigui d’acord File SHA-256: {hash} SHA-256 del fitxer: {hash} Read the objects it is given Llegir els objectes que rep Change, add and delete objects in the document Canviar, afegir i suprimir objectes del document @@ -5853,21 +5942,43 @@ 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 Show its progress, see Cancel and write log lines Mostrar el progrés, detectar la cancel·lació i escriure línies de registre -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permís `progress` necessita el nivell d’API de complements {PROGRESS_API}: indiqueu `api` {PROGRESS_API} + Keep settings of its own Desar una configuració pròpia + Keep a cache of its own, which you can clear Desar una memòria cau pròpia, que podeu buidar + Clear Plug-in Cache Buida la memòria cau del complement +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permís `{permission}` necessita el nivell d’API de complements {PROGRESS_API}: indiqueu `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} el mòdul importa `env.{name}` amb un tipus diferent del de l’amfitrió: el de l’amfitrió és {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` el mòdul importa `env.{name}`, que un complement només pot fer servir al nivell d’API de complements {PROGRESS_API} (`api` {PROGRESS_API}) i amb el permís `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network el mòdul importa `{from}.{name}`, que l’amfitrió no ofereix: un complement només pot importar `env.vc_progress`, `env.vc_cancelled` i `env.vc_log` (nivell d’API de complements 2), i cap WASI, fitxer ni xarxa +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` el mòdul importa `env.{name}`, que un complement només pot fer servir al nivell d’API de complements {PROGRESS_API} (`api` {PROGRESS_API}) i amb el permís `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network el mòdul importa `{from}.{name}`, que l’amfitrió no ofereix: un complement només pot importar les funcions `env.vc_*` que enumera docs/plugins.md (nivell d’API de complements 2), i cap WASI, fitxer ni xarxa @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` may only be true `paintOutput` només pot ser true +@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}` @msg at most {MAX_PERMISSIONS} `permissions` com a màxim {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` el permís `{shown}` no s’aplica a un complement del tipus `{_1}` @msg permission `{shown}` is listed twice el permís `{shown}` apareix dues vegades +@msg only a filter declares `inputs` només un filtre declara `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` anomena un tipus d’objecte desconegut `{shown}`: feu servir type, image o symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` inclou `{shown}` dues vegades +@msg `inputs` needs `api` 2 or later `inputs` requereix `api` 2 o posterior +@msg `inputs` needs the permission `doc.read` `inputs` requereix el permís `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) pressupost de memòria (l’objecte {_1} necessita {size} bytes d’entrada, en queden {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) pressupost d’objectes (més de {MAX_OBJECTS} objectes de només lectura) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permís `{shown}` està reservat per a una API de complements posterior; aquest amfitrió no el concedeix @msg unknown permission `{shown}` permís desconegut `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» demana els permisos {asked}: instal·la’l amb un `grant` que els enumeri quan l’usuari hi estigui d’acord +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants «{name}» demana els permisos {asked}: una persona els ha d’aprovar a l’aplicació d’escriptori, o passa `grant` en una sessió iniciada amb --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` i `allowHeavy` només tenen efecte en una sessió iniciada amb --allow-plugin-grants: aprova el complement a l’aplicació d’escriptori +@msg Only the person at the keyboard can approve installing a plug-in Només la persona al teclat pot aprovar la instal·lació d’un complement @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a «{name}» no se li ha concedit `{p}`, que la seva sortida necessita: no s’ha canviat res +@msg a heavy filter runs in the background, so it can't declare `commands` un filtre pesat s’executa en segon pla, de manera que no pot declarar `commands` +@msg at most {MAX_COMMANDS} `commands` com a màxim {MAX_COMMANDS} `commands` +@msg command {name} is declared twice l’ordre {name} està declarada dues vegades +@msg command {name}: `label` must be 1-64 printable characters ordre {name}: `label` ha de tenir 1-64 caràcters imprimibles +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter ordre {name}: fes servir un id d’1-{MAX_COMMAND_ID} caràcters d’a-z 0-9 _ que comenci per una lletra +@msg command {name}: {e} ordre {name}: {e} +@msg only a filter or a live effect declares `commands` només un filtre o un efecte dinàmic declara `commands` +@msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed el mòdul no té el SHA-256 que indica `hash`: no s’ha instal·lat res @msg “{name}” is not a file «{name}» no és un fitxer axis Both Tots dos @@ -6121,6 +6232,9 @@ anchor Points: Punts: CMYK Images Imatges CMYK Grayscale Images Imatges en escala de grisos @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` ha de ser spotColors, colorEffects, rgbImages, cmykImages o grayImages +@msg byColor must be true or false byColor ha de ser true o false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor no pot separar els grups de l'agrupació automàtica: traça sense l'agrupació automàtica +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor no pot tornar a agrupar un traçat amb un efecte Pathfinder actiu: expandeix-lo sense byColor # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art Color dels punts de la il·lustració @@ -6242,10 +6356,16 @@ breakText Glyphs Glifs @msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` ha de ser lines, paragraphs, words o glyphs, no {other} @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} File + Number Fitxer + número File + Data Set Name Fitxer + nom del conjunt de dades Data Set Name Nom del conjunt de dades @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` ha de ser `fileNumber`, `fileDataSetName` o `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Cada conjunt de dades es desa sobre el mateix fitxer, que només conserva l'últim conjunt de dades. +@msg save the document with Save As before Save and Close deseu el document amb «Desa com a» abans de «Desa i tanca» @msg no free data set file name in destination folder No hi ha cap nom de fitxer de conjunt de dades lliure a la carpeta de destinació # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6294,6 +6414,9 @@ breakText Glyphs Glifs @msg the JPEG 2000 file can't be read no es pot llegir el fitxer JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content el fitxer JPEG 2000 té un espai de color o contingut JPX no compatible +@msg the HEIC or HEIF file can't be read no es pot llegir el fitxer HEIC o HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read el fitxer HEIC o HEIF conté un tipus d'imatge que no es pot llegir +@msg HEIC and HEIF images can't be opened in this build les imatges HEIC i HEIF no es poden obrir en aquesta compilació @msg You must choose a side pattern. Heu de triar un patró lateral. @msg invalid pattern tile choice opció de mosaic de patró no vàlida @msg no pattern named `{choice}` no hi ha cap patró anomenat `{choice}` @@ -6367,9 +6490,20 @@ breakText Glyphs Glifs Reset Symbol Instance Restableix la instància de símbol @msg type must be static or dynamic, not {v} type ha de ser static o dynamic, no {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` és un símbol estàtic: primer establiu el tipus de símbol a Dinàmic +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change D'un traçat, un traçat compost o un text seleccionat en una instància de símbol només es pot canviar el color sòlid de l'emplenament i el traç @msg path must be a list of child indices path ha de ser una llista d’índexs de fills @msg no object at path {shown} in the symbol's art no hi ha cap objecte al camí {shown} de l’art del símbol @msg give fill, stroke, opacity and/or blend indiqueu fill, stroke, opacity i/o blend +@msg {key} cannot be set on a guide {key} no es pot definir en una guia +@msg {key} cannot be set on a blend {key} no es pot definir en una fusió +@msg {key} cannot be set on an envelope {key} no es pot definir en un embolcall +@msg {key} cannot be set on a gradient mesh {key} no es pot definir en una malla de degradat +@msg {key} cannot be set on a Repeat {key} no es pot definir en una repetició +@msg {key} cannot be set on a placed document {key} no es pot definir en un document col·locat +@msg {key} cannot be set on an Intertwine group {key} no es pot definir en un grup entrelligat +@msg stroke cannot be set on a symbol instance stroke no es pot definir en una instància de símbol +@msg fill on a symbol instance must be a solid color or none el fill d’una instància de símbol ha de ser un color sòlid o none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} no es pot definir en una imatge sense l’efecte Converteix en forma o Contorneja l’objecte @msg pass newName, registration, exportType, nineSlice and/or type indiqueu newName, registration, exportType, nineSlice i/o type Text Flow Flux del text By Rows Per files @@ -6399,6 +6533,7 @@ breakText Glyphs Glifs Create Swatch Legend Crea una llegenda de mostres Create Swatch Legend… Crea una llegenda de mostres… HEX HEX + Help Bar Barra d’ajuda # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Eines de text Text Tweaker… Ajustador de text… @@ -6464,3 +6599,44 @@ 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 plug-in of kind `{_2}`, not an importer `{_1}` és un complement del tipus `{_2}`, no un importador +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` és un complement del tipus `{_2}`, no un exportador +@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` + +# Safe Mode + Safe Mode… Mode segur… + Safe Mode Mode segur + Help › Safe Mode… Ajuda › Mode segur… + Enable on Re-launch Activa en el proper inici + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. L'últim inici de VectorCraft es va aturar mentre es carregava el complement {name}. Pot ser que no en sigui la causa. + Disable and Continue Desactiva i continua + Start Normally Inicia normalment + Start in Safe Mode Inicia en mode segur + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft és en mode segur: cap complement no es carrega ni s'executa fins que s'iniciï normalment. + Disabled plug-ins Complements desactivats + No plug-ins are disabled. No hi ha cap complement desactivat. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally El mode segur és actiu: els complements no es carreguen ni s'executen fins que VectorCraft s'iniciï normalment +@msg give `id` or `file` (see plugin.safeMode) indiqueu `id` o `file` (vegeu plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) aquest complement no està desactivat (vegeu plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch No s’han pogut desar els complements desactivats per al proper inici +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Expandeix l’aparença: un efecte d’un complement de la selecció no es pot expandir en mode segur + +# Align to Pixel Grid + Align to Pixel Grid Alinea a la quadrícula de píxels +@msg only paths and compound paths can be aligned to the pixel grid només els traçats i els traçats compostos es poden alinear a la quadrícula de píxels +@msg side is left or right, not `{s}` side ha de ser left o right, no `{s}` diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7e51de244..204e73da9 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -150,6 +150,7 @@ Report copied Zpráva zkopírována Line {line} Řádek {line} Line {line}: {name}: {status} Řádek {line}: {name}: {status} + Read Action Set File Přečíst soubor sady akcí Read Shortcut Set File Přečíst soubor sady klávesových zkratek Import Matches Přiřazení z importů Remember Import Matches Zapamatovat přiřazení z importů @@ -187,6 +188,11 @@ Ungroup Oddělit Lock Zamknout Selection Výběr +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Obdélníkový výběr + Touching Dotčené objekty + Fully Enclosed Zcela uvnitř + Drag Direction Směr tažení All Artwork Above Veškerou kresbu výše Other Layers Ostatní vrstvy Unlock All Odemknout vše @@ -334,6 +340,13 @@ Apply Bullets Použít odrážky Numbered List Číslovaný seznam Apply Numbers Použít číslování + Bullets and Numbering… Odrážky a číslování… + Numbers Čísla + Level: Úroveň: + Bullet Character: Znak odrážky: + Number Style: Styl číslování: + Left Indent: Odsazení zleva: + First Line Indent: Odsazení prvního řádku: Convert to Text Převést na text Filled Dot Plná tečka Dash Pomlčka @@ -462,6 +475,17 @@ Inflate Nafouknutí Select objects, then pick an object type to give them a 3D effect. Vyberte objekty a pak zvolte typ objektu, abyste jim dali 3D efekt. Extrude & Bevel… Vysunutí a zkosení… + 3D (Classic) 3D (klasické) + Extrude & Bevel (Classic)… Vysunutí a zkosení (klasické)… + Surface Povrch + Wireframe Drátový model + No Shading Bez stínování + Plastic Plast + Highlight Intensity Intenzita odlesku + Highlight Size Velikost odlesku + Blend Steps Kroky prolnutí + Shading Color Barva stínování + Draw Hidden Faces Kreslit skryté stěny Revolve… Rotační těleso… Inflate… Nafouknutí… Materials… Materiály… @@ -673,6 +697,7 @@ Toolbars Panely nástrojů Advanced Rozšířené Double Column Dva sloupce + Keep Tools Beside the Canvas Nástroje vedle plátna Actions Akce Asset Export Export prvků Attributes Atributy @@ -680,6 +705,11 @@ Color Barva Color Guide Průvodce barvami Color Themes Barevné motivy + Color Wheel Barevné kolo + Hue ring Prstenec odstínů + Saturation and brightness Sytost a jas + Color field Barevné pole + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties Vlastnosti CSS Document Info Informace o dokumentu Flattener Preview Náhled sloučení @@ -888,6 +918,9 @@ Stained Glass… Vitráž… Texturizer… Texturovač… System Title Bar Systémové záhlaví okna + VectorCraft Title Bar on Every Desktop Záhlaví okna VectorCraftu v každém desktopovém prostředí + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Dokud je proměnná prostředí VECTORCRAFT_TITLEBAR nastavena, záhlaví okna volí ona. + System Title Bar takes precedence over this option while it is on. Dokud je Systémové záhlaví okna zapnuté, má přednost před touto volbou. Exit Ukončit Distort Zkreslení Diffuse Glow… Rozptýlená záře… @@ -1256,7 +1289,7 @@ lighting Light Světlo X: X: Y: Y: Drag to float the toolbar Přetažením vytvoříte plovoucí panel nástrojů - Drag to the window's left edge to dock Přetažením k levému okraji okna panel ukotvíte + Drag to the window's left or right edge to dock Přetažením k levému nebo pravému okraji okna panel ukotvíte # Tools Crystallize Krystalizace @@ -1515,6 +1548,15 @@ digits Malayalam Malajálamské Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Nastavení Tate-chu-yoko… Tate-chu-yoko Settings Nastavení Tate-chu-yoko + Composite Fonts… Složená písma… + Composite Fonts Složená písma + Export Composite Fonts Exportovat složená písma + Import Composite Fonts Importovat složená písma + Delete Font Odstranit písmo + Kanji Kandži + Kana Kana + Numerals Číslice + Scale at Center Měřítko od středu Up/Down: Nahoru/dolů: Left/Right: Vlevo/vpravo: Underline Podtržení @@ -1895,6 +1937,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 @@ -2673,6 +2719,7 @@ digits Malayalam Malajálamské Tile Range: Rozsah dlaždic: tile {number} dlaždice {number} Tiles are numbered across, then down; leave the range empty for every tile. Dlaždice se číslují po řádcích, pak dolů; prázdný rozsah znamená všechny dlaždice. + Copies per Sheet (as many as fit, in a grid) Kopie na list (kolik se vejde, v mřížce) Transverse (the page a quarter turn on the paper) Napříč (stránka otočená na papíře o čtvrt otáčky) Unprintable margin: Netisknutelný okraj: {pages} in all, {count} per copy celkem {pages}, na kopii {count} @@ -2743,6 +2790,10 @@ digits Malayalam Malajálamské < 1 sec < 1 s [Unnamed] [Bez názvu] Amount: Míra: + Anti-aliasing Method Metoda vyhlazování +antiAlias Sharp Ostré + Crisp Jasné + Strong Silné Anti-aliasing: Vyhlazování: Artboard: Pracovní plocha: Color Table Tabulka barev @@ -3181,6 +3232,14 @@ theme Appearance Motiv Asset Export Settings Nastavení exportu prvků Assets Prvky Export Assets Exportovat prvky + Export 3D Object Exportovat 3D objekt + 3D Export Format Formát 3D exportu + Export 3D Assets Exportovat 3D prvky +@msg {what} can't be exported in 3D: {e} {what} nelze exportovat ve 3D: {e} +@msg asset {id} isn't a 3D asset prvek {id} není 3D prvek +@msg asset {id} is a 3D asset: assets.export3D writes it prvek {id} je 3D prvek: zapisuje jej assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) zatím žádné 3D prvky: nejprve shromážděte objekt s 3D efektem (assets.add3D) +@msg select an object with a 3D effect (or give its ids) vyberte objekt s 3D efektem (nebo zadejte jeho ID) Remove Asset Odebrat prvek Rename Asset Přejmenovat prvek Fill Rule Pravidlo výplně @@ -3316,6 +3375,7 @@ theme Appearance Motiv Document JSON JSON dokumentu Duplicate Duplikovat Find Objects Najít objekty + Compare with Image Porovnat s obrazem Inspect Document Prozkoumat dokument Inspect Object Prozkoumat objekt Begin Undo Group Zahájit skupinu kroků zpět @@ -3619,6 +3679,7 @@ theme Appearance Motiv Feet Stopy Files Soubory Fill New Type Objects With Placeholder Text Vyplňovat nové textové objekty zástupným textem + Start Lists From Typed Markers Začínat seznamy z napsaných značek Folder Složka Font Preview Size Velikost náhledu písma Get Preferences Získat předvolby @@ -3743,6 +3804,7 @@ theme Appearance Motiv Yards Yardy Zoom to Selection Zvětšit na výběr Zoom with Mouse Wheel Zvětšovat kolečkem myši + Scroll Sideways with Cmd/Ctrl + Wheel Posouvat vodorovně kolečkem s Cmd/Ctrl Scrub Numeric Fields by Dragging Měnit číselné hodnoty tažením Linux Display Server Zobrazovací server Linuxu Print Preview Náhled tisku @@ -4029,6 +4091,7 @@ effect Regular Pravidelný Find in Folder… Najít ve složce… Replace With Font Nahradit písmem Replace With Font From Nahradit písmem z + Find In Hledat v System Systém — substituted by {style} — nahrazeno řezem {style} — {n} characters from another font — znaky z jiného písma: {n} @@ -4278,7 +4341,7 @@ imageTrace Outline Obrys # Status and error messages @msg no library `{id}` knihovna `{id}` neexistuje @msg select some type first nejprve vyberte text -@msg kerning must be a number, "auto", or "optical" Vyrovnání párů musí být číslo, „auto“ nebo „optical“ +@msg kerning must be a number, "auto", or "optical" kerning musí být číslo, „auto“ nebo „optical“ @msg the selection has no gradient fill výběr nemá přechodovou výplň @msg the selection has no gradient stroke výběr nemá přechodový tah @msg the selection has no pattern fill výběr nemá vzorkovou výplň @@ -4381,6 +4444,7 @@ imageTrace Outline Obrys @msg Split Into Grid: select paths large enough for the grid Rozdělit do mřížky: vyberte cesty dost velké pro mřížku @msg Targa files are RGB: colorModel rgb soubory Targa jsou RGB: colorModel rgb @msg Text Wrap: select a wrap object Obtékání textu: vyberte objekt obtékání +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` musí být "none", "sharp", "crisp" nebo "strong" @msg `aspect` must be a number (%) `aspect` musí být číslo (%) @msg `at` must be [x, y] `at` musí být [x, y] @msg `color` must be a colour `color` musí být barva @@ -4490,7 +4554,7 @@ imageTrace Outline Obrys @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art nebo type @msg artboard {0} does not exist pracovní plocha {0} neexistuje @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard v tomto měřítku má kresba {w} × {h} pt, víc než pojme plátno: zvolte menší měřítko nebo ji přizpůsobte pracovní ploše -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` osa musí být "vertical", "horizontal" nebo úhel ve stupních, ne `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis musí být "vertical", "horizontal" nebo úhel ve stupních, ne `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" pozadí {other}: transparent, white, black nebo barva jako "#ff8800" @msg bad color {v} chybná barva {v} @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio musí být číslo od 0,1 do 10 @@ -4504,9 +4568,9 @@ imageTrace Outline Obrys @msg bad shortcut chybná klávesová zkratka @msg bad stream length chybná délka datového proudu @msg bad trailer chybný trailer -@msg balance must be between 0 and 100 vyvážení musí být mezi 0 a 100 +@msg balance must be between 0 and 100 balance musí být mezi 0 a 100 @msg batch step {_1} (`{id}`) failed: {e} krok dávky {_1} (`{id}`) selhal: {e} -@msg brightness must be dark|mediumDark|mediumLight|light jas musí být dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light brightness musí být dark|mediumDark|mediumLight|light @msg can't encode the document: {0} dokument nelze zakódovat: {0} @msg can't parse shortcut `{c}` klávesovou zkratku `{c}` nelze analyzovat @msg can't parse shortcut `{s}` for `{k}` klávesovou zkratku `{s}` pro `{k}` nelze analyzovat @@ -4531,7 +4595,7 @@ imageTrace Outline Obrys @msg compound paths can only contain paths složené cesty mohou obsahovat jen cesty @msg compressed (RLE) bitmaps can't be read yet komprimované bitmapy (RLE) zatím nelze načíst @msg damaged poškozeno -@msg decimals must be {_1}–{_2} (got {_3}) počet desetinných míst musí být {_1}–{_2} (zadáno {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals musí být {_1}–{_2} (zadáno {_3}) @msg divide into 1 to {MAX_PIECES} slices rozdělit lze na 1 až {MAX_PIECES} řezů @msg effect {k} is `{_1}`, not `{id}` efekt {k} je `{_1}`, ne `{id}` @msg empty brush stroke prázdný tah štětcem @@ -4656,6 +4720,8 @@ imageTrace Outline Obrys @msg no such action set taková sada akcí neexistuje @msg the action sets changed; nothing was deleted sady akcí se změnily; nic nebylo odstraněno @msg no action set to save žádná sada akcí k uložení +@msg the action set file can't be read in full soubor sady akcí nelze přečíst celý +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Načteno „{name}“: vynechán {n} krok|Načteno „{name}“: vynechány {n} kroky|Načteno „{name}“: vynecháno {n} kroků @msg not an action set file není to soubor sady akcí @msg the action set file is larger than 8 MiB soubor sady akcí je větší než 8 MiB @msg the action set file holds more than 1,000 actions soubor sady akcí obsahuje více než 1 000 akcí @@ -4729,9 +4795,9 @@ imageTrace Outline Obrys @msg replace: the object to replace was changed or deleted replace: objekt k nahrazení byl změněn nebo odstraněn @msg pattern missing chybí vzorek @msg patterns nested too deeply are filled with mid-grey příliš hluboko vnořené vzorky se vyplní středně šedou -@msg percent must be above 0 and at most 6400, not {p} procento musí být větší než 0 a nejvýše 6400, ne {p} +@msg percent must be above 0 and at most 6400, not {p} percent musí být větší než 0 a nejvýše 6400, ne {p} @msg pick a colour, gradient or pattern to save as a swatch vyberte barvu, přechod nebo vzorek, který se má uložit do vzorníku -@msg plane must be left, right or ground rovina musí být left, right nebo ground +@msg plane must be left, right or ground plane musí být left, right nebo ground @msg plug-in ABI: {0} ABI zásuvného modulu: {0} @msg plug-in exceeded its {0} zásuvný modul překročil limit: {0} @msg plug-in failed: {0} zásuvný modul selhal: {0} @@ -4750,8 +4816,8 @@ imageTrace Outline Obrys @msg result would exceed the canvas (coordinates out of range) výsledek by přesáhl plátno (souřadnice mimo rozsah) @msg objects can't be nested more than 32 deep objekty nelze vnořit hlouběji než do 32 úrovní @msg the SVG's elements are nested more than {0} deep prvky SVG jsou vnořeny hlouběji než do {0} úrovní -@msg scale must be a number from {_1} to {_2}, not {_3} měřítko musí být číslo od {_1} do {_2}, ne {_3} -@msg scale must be a number from {_1} to {_2}, not {s} měřítko musí být číslo od {_1} do {_2}, ne {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale musí být číslo od {_1} do {_2}, ne {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale musí být číslo od {_1} do {_2}, ne {s} @msg select (or target) objects whose appearance isn't basic vyberte (nebo zacilte) objekty, jejichž vzhled není základní @msg select a Live Paint group vyberte skupinu živé malby @msg select a blend vyberte prolnutí @@ -4779,6 +4845,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 @@ -5016,11 +5089,15 @@ imageTrace Outline Obrys @msg its objects are nested too deeply jeho objekty jsou vnořené příliš hluboko @msg its section ending with `%{end}` doesn't end jeho oddíl končící `%{end}` nemá konec @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) štětce, které VectorCraft zatím neumí načíst, byly vynechány z panelu Štětce ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekty s více výplněmi nebo tahy, efekty či štětci byly načteny jako vykreslený vzhled ({_1}): jejich vzhled zatím nelze upravovat +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekty s více výplněmi nebo tahy, tahy s proměnnou šířkou, efekty či štětci byly načteny jako vykreslený vzhled ({_1}): jejich vzhled zatím nelze upravovat +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding název `{name}` není v UTF-8 a byl načten jako {label}: pokud vypadá špatně, otevřete soubor s jiným legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding název `{name}` není v UTF-8 a jeho části nejsou platné ani v {label}: otevřete soubor s jiným legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) další názvy, které nejsou v UTF-8, byly načteny jako {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it přechod „{name}“ není v souboru definován: zastupuje ho plná barva @msg an image VectorCraft can't read was left out obrázek, který VectorCraft neumí přečíst, byl vynechán @msg graphs come in as plain art, without their data or settings grafy se načtou jako obyčejná grafika, bez svých dat a nastavení @msg an image whose data is cut short was left out obrázek s useknutými daty byl vynechán +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers vložený obrázek o rozměrech {_1} × {_2} pixelů byl zmenšen na {_3} × {_4} pixelů, aby se zachovaly vrstvy @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel propojené obrázky se zobrazují jako náhled v nízkém rozlišení uložený v souboru, dokud se nenajdou jejich soubory: znovu je propojte v panelu Propojení @msg linked type areas past VectorCraft's limits were left out propojené textové oblasti nad limity aplikace VectorCraft byly vynechány @msg type that changes writing direction inside a text object opens in the direction of its first character text, který mění směr psaní uvnitř textového objektu, se otevře ve směru svého prvního znaku @@ -5056,6 +5133,7 @@ imageTrace Outline Obrys @msg object ids are non-negative integers, not {v} ID objektů jsou nezáporná celá čísla, ne {v} @msg {key} must be an array of non-negative integer object ids {key} musí být pole ID objektů, která jsou nezáporná celá čísla @msg invalid object id {value}: expected a non-negative integer neplatné ID objektu {value}: očekává se nezáporné celé číslo +@msg `{key}` entry {index} is {value}, not a non-negative integer object id položka {index} v `{key}` je {value}, nikoli ID objektu (nezáporné celé číslo) @msg no such object id {value} objekt s ID {value} neexistuje @msg id {value} is a layer, not an object ID {value} je vrstva, nikoli objekt @msg id {value} is a layer, not an object; move layers with layer.move ID {value} je vrstva, nikoli objekt; vrstvy přesunete pomocí layer.move @@ -5082,12 +5160,19 @@ imageTrace Outline Obrys @msg the Pixar file's image data is damaged obrazová data souboru Pixar jsou poškozená @msg the JPEG 2000 file can't be read soubor JPEG 2000 nelze přečíst @msg the JPEG 2000 file has an unsupported color space or JPX content soubor JPEG 2000 obsahuje nepodporovaný barevný prostor nebo obsah JPX +@msg the HEIC or HEIF file can't be read soubor HEIC nebo HEIF nelze přečíst +@msg the HEIC or HEIF file holds a kind of image that can't be read soubor HEIC nebo HEIF obsahuje druh obrázku, který nelze přečíst +@msg HEIC and HEIF images can't be opened in this build obrázky HEIC a HEIF nelze v tomto sestavení otevřít @msg the PSD image is too large to read ({_1} × {_2} pixels) obraz PSD je příliš velký na přečtení ({_1} × {_2} obrazových bodů) @msg zipped 32-bit PSD image data with prediction can't be read zazipovaná 32bitová obrazová data PSD s predikcí nelze přečíst @msg {depth}-bit PSD files in this colour mode can't be read {depth}bitové soubory PSD v tomto barevném režimu nelze přečíst @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 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} @msg Invalid panel layout; restored the default dock Neplatné rozložení panelů; obnoveno výchozí ukotvení @msg action is required action je povinné @@ -5512,6 +5597,11 @@ mark-style Japanese Japonské @msg unknown parameter `{key}`; the command takes: {keys} neznámý parametr `{key}`; příkaz přijímá: {keys} Load EPS into Clipboard Načíst EPS do schránky @msg an image of {w} × {h} pixels is too large to draw obraz o velikosti {w} × {h} obrazových bodů je příliš velký na vykreslení +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: není obraz PNG, JPEG, WebP, TIFF ani BMP +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ obraz má {iw} × {ih} obrazových bodů a pracovní plocha v této šířce má {rw} × {rh} obrazových bodů: jejich proporce se liší +@msg `threshold` must be a number from 0 to 1 `threshold` musí být číslo od 0 do 1 +@msg background {v}: a color such as "#808080" pozadí {v}: barva jako "#808080" +@msg comparing with an image file isn't available in the browser porovnání se souborem obrazu není v prohlížeči k dispozici @msg an image of {w} × {h} pixels is shown at reduced size obraz o velikosti {w} × {h} obrazových bodů se zobrazuje ve zmenšené velikosti @msg the image can't be read as a texture obraz nelze načíst jako texturu Fill and Stroke Controls Ovládací prvky výplně a tahu @@ -5705,11 +5795,19 @@ mark-style Japanese Japonské @msg too many library panels were opened in this session v této relaci bylo otevřeno příliš mnoho panelů knihoven @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Obraz má {_1} × {_2} pixelů, na jedné straně více než {LARGE_TRACE_SIDE}: vektorizuje se, ale pomaleji. Rychleji se vektorizuje s předvolbou nižší věrnosti nebo menší kopie obrazu. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Obraz byl vektorizován v rozlišení {_1} × {_2} pixelů, zmenšený z {_3} × {_4} (nejvýše {_5} megapixelů). +@msg byColor must be true or false byColor musí být true nebo false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor nerozdělí skupiny z Automatického seskupení: vektorizujte bez Automatického seskupení +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor nemůže přeskupit trasování s živým efektem Pathfinder: rozbalte bez byColor Snapping Přichytávání Snap to Tangent Přichytávat k tečně Snap to Perpendicular Přichytávat ke kolmici @msg unknown snap option `{key}` (use {options}) neznámá možnost přichytávání `{key}` (použijte {options}) Gap Length: Délka mezery: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Režim Ploché logo nepoužívá keepColors: vektorizuje barvy uvedené v logoColors nebo barvy, které najde. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Nic nebylo vektorizováno: žádná z barev, které vektorizace našla, neodpovídá keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Nic nebylo vektorizováno: Ignorovat barvu vynechává všechny oblasti barev uvedených v keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Nic nebylo vektorizováno: z barev odpovídajících keepColors nevznikly žádné cesty. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: žádná z barev, které vektorizace našla, neodpovídá {c}, takže z ní není nic vektorizováno. @msg `colorGroup` needs `library: "document"` `colorGroup` vyžaduje `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` vyžaduje `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` vyžaduje `mode: "color"` @@ -5821,11 +5919,14 @@ axis Y Axis osa Y @msg Running “{name}”… Spouští se „{name}“… @msg a live effect runs while the document is drawn, so it can't be `heavy` živý efekt běží během vykreslování dokumentu, takže nemůže být `heavy` @msg an exporter always gets an export's budget: leave `budget` out exportní zásuvný modul dostává vždy rozpočet exportu: klíč `budget` vynechte -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ žádá o velký výpočetní rozpočet (každé spuštění až {minutes} minut a {times}krát víc instrukcí než obvykle): nainstalujte ho s `allowHeavy: true`, jakmile uživatel souhlasí +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}“ žádá o velký výpočetní rozpočet (každé spuštění až {minutes} minut a {times}krát víc instrukcí než obvykle): musí ho schválit člověk v desktopové aplikaci, nebo předejte `allowHeavy: true` v relaci spuštěné s --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Nainstalovat „{name}“, který žádá o tato oprávnění? ID: {id} ID: {id} Version: {version} Verze: {version} + Install “{name}” in place of the plug-in with the same ID? Nainstalovat „{name}“ místo zásuvného modulu se stejným ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Tento soubor není zásuvný modul dříve nainstalovaný s tímto ID. Získá nastavení a mezipaměť uchovávané pro toto ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}“ je jiný modul než zásuvný modul s ID {_1} a získal by nastavení a mezipaměť uchovávané pro něj: nainstalujte ho s `replace: true`, jakmile uživatel souhlasí File SHA-256: {hash} SHA-256 souboru: {hash} Read the objects it is given Číst objekty, které dostane Change, add and delete objects in the document Měnit, přidávat a mazat objekty v dokumentu @@ -5833,21 +5934,43 @@ 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 Show its progress, see Cancel and write log lines Zobrazit průběh, rozpoznat zrušení a zapisovat řádky protokolu -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} oprávnění `progress` vyžaduje úroveň API zásuvných modulů {PROGRESS_API}: uveďte `api` {PROGRESS_API} + Keep settings of its own Uchovávat vlastní nastavení + Keep a cache of its own, which you can clear Uchovávat vlastní mezipaměť, kterou můžete vymazat + Clear Plug-in Cache Vymazat mezipaměť zásuvného modulu +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} oprávnění `{permission}` vyžaduje úroveň API zásuvných modulů {PROGRESS_API}: uveďte `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} modul importuje `env.{name}` s jiným typem, než má hostitel: typ hostitele je {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modul importuje `env.{name}`, který zásuvný modul smí používat jen na úrovni API zásuvných modulů {PROGRESS_API} (`api` {PROGRESS_API}) a s oprávněním `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modul importuje `{from}.{name}`, který hostitel nenabízí: zásuvný modul smí importovat jen `env.vc_progress`, `env.vc_cancelled` a `env.vc_log` (úroveň API zásuvných modulů 2), a žádné WASI, soubory ani síť +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` modul importuje `env.{name}`, který zásuvný modul smí používat jen na úrovni API zásuvných modulů {PROGRESS_API} (`api` {PROGRESS_API}) a s oprávněním `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network modul importuje `{from}.{name}`, který hostitel nenabízí: zásuvný modul smí importovat jen funkce `env.vc_*`, které uvádí docs/plugins.md (úroveň API zásuvných modulů 2), a žádné WASI, soubory ani síť @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` may only be true `paintOutput` může být jen true +@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}` @msg at most {MAX_PERMISSIONS} `permissions` nejvýše {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` oprávnění `{shown}` se nevztahuje na zásuvný modul druhu `{_1}` @msg permission `{shown}` is listed twice oprávnění `{shown}` je uvedeno dvakrát +@msg only a filter declares `inputs` `inputs` deklaruje pouze filtr +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` uvádí neznámý druh objektu `{shown}`: použijte type, image nebo symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` uvádí `{shown}` dvakrát +@msg `inputs` needs `api` 2 or later `inputs` vyžaduje `api` 2 nebo vyšší +@msg `inputs` needs the permission `doc.read` `inputs` vyžaduje oprávnění `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) rozpočet paměti (objekt {_1} potřebuje {size} bajtů vstupu, zbývá {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) rozpočet objektů (více než {MAX_OBJECTS} objektů jen pro čtení) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it oprávnění `{shown}` je vyhrazeno pro pozdější API zásuvných modulů; tento hostitel ho neuděluje @msg unknown permission `{shown}` neznámé oprávnění `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ žádá o oprávnění {asked}: nainstalujte ho s `grant`, který je uvádí, jakmile uživatel souhlasí +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants „{name}“ žádá o oprávnění {asked}: musí je schválit člověk v desktopové aplikaci, nebo předejte `grant` v relaci spuštěné s --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` a `allowHeavy` platí jen v relaci spuštěné s --allow-plugin-grants: schvalte zásuvný modul v desktopové aplikaci +@msg Only the person at the keyboard can approve installing a plug-in Instalaci zásuvného modulu může schválit jen člověk u klávesnice @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“ nedostal `{p}`, které jeho výstup potřebuje: nic se nezměnilo +@msg a heavy filter runs in the background, so it can't declare `commands` těžký filtr běží na pozadí, takže nemůže deklarovat `commands` +@msg at most {MAX_COMMANDS} `commands` nejvýše {MAX_COMMANDS} `commands` +@msg command {name} is declared twice příkaz {name} je deklarován dvakrát +@msg command {name}: `label` must be 1-64 printable characters příkaz {name}: `label` musí mít 1-64 tisknutelných znaků +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter příkaz {name}: použijte id o 1-{MAX_COMMAND_ID} znacích z a-z 0-9 _, které začíná písmenem +@msg command {name}: {e} příkaz {name}: {e} +@msg only a filter or a live effect declares `commands` `commands` deklaruje jen filtr nebo živý efekt +@msg “{name}” › {label}: {e} „{name}“ › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modul nemá SHA-256 uvedený v `hash`: nic nebylo nainstalováno # Actions panel: action sets (#3712). New Set… Nová sada… @@ -6265,6 +6388,8 @@ breakText Glyphs Glyfy File + Data Set Name Soubor + název sady dat Data Set Name Název sady dat @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` musí být `fileNumber`, `fileDataSetName` nebo `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Každá sada dat se uloží přes stejný soubor, ve kterém zůstane jen poslední sada dat. +@msg save the document with Save As before Save and Close před „Uložit a zavřít“ uložte dokument pomocí „Uložit jako“ @msg no free data set file name in destination folder V cílové složce není volný název souboru sady dat # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6384,9 +6509,20 @@ breakText Glyphs Glyfy Reset Symbol Instance Obnovit instanci symbolu @msg type must be static or dynamic, not {v} type musí být static nebo dynamic, ne {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` je statický symbol: nejprve nastavte jeho typ symbolu na Dynamický +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change U cesty, složené cesty nebo textu vybraného v instanci symbolu lze měnit jen plnou barvu výplně a tahu @msg path must be a list of child indices path musí být seznam indexů potomků @msg no object at path {shown} in the symbol's art v kresbě symbolu není na cestě {shown} žádný objekt @msg give fill, stroke, opacity and/or blend zadejte fill, stroke, opacity a/nebo blend +@msg {key} cannot be set on a guide {key} nelze nastavit u vodítka +@msg {key} cannot be set on a blend {key} nelze nastavit u prolnutí +@msg {key} cannot be set on an envelope {key} nelze nastavit u obálky +@msg {key} cannot be set on a gradient mesh {key} nelze nastavit u přechodové sítě +@msg {key} cannot be set on a Repeat {key} nelze nastavit u opakování +@msg {key} cannot be set on a placed document {key} nelze nastavit u umístěného dokumentu +@msg {key} cannot be set on an Intertwine group {key} nelze nastavit u propletené skupiny +@msg stroke cannot be set on a symbol instance stroke nelze nastavit u instance symbolu +@msg fill on a symbol instance must be a solid color or none fill instance symbolu musí být plná barva nebo none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} nelze nastavit u obrazu bez efektu Převést na tvar nebo Převést objekt na obrys @msg pass newName, registration, exportType, nineSlice and/or type zadejte newName, registration, exportType, nineSlice a/nebo type Text Flow Tok textu By Rows Po řádcích @@ -6416,6 +6552,7 @@ breakText Glyphs Glyfy Create Swatch Legend Vytvořit legendu vzorníku Create Swatch Legend… Vytvořit legendu vzorníku… HEX HEX + Help Bar Panel nápovědy # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Nástroje textu Text Tweaker… Ladění textu… @@ -6481,3 +6618,44 @@ 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 plug-in of kind `{_2}`, not an importer `{_1}` je zásuvný modul druhu `{_2}`, ne importní zásuvný modul +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` je zásuvný modul druhu `{_2}`, ne exportní 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` + +# Safe Mode + Safe Mode… Nouzový režim… + Safe Mode Nouzový režim + Help › Safe Mode… Nápověda › Nouzový režim… + Enable on Re-launch Povolit při příštím spuštění + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Poslední spuštění aplikace VectorCraft se zastavilo během načítání zásuvného modulu {name}. Tento modul nemusí být příčinou. + Disable and Continue Zakázat a pokračovat + Start Normally Spustit normálně + Start in Safe Mode Spustit v nouzovém režimu + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft je v nouzovém režimu: zásuvné moduly se nenačítají ani nespouštějí, dokud se aplikace nespustí normálně. + Disabled plug-ins Zakázané zásuvné moduly + No plug-ins are disabled. Žádné zásuvné moduly nejsou zakázány. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Nouzový režim je zapnutý: zásuvné moduly se nenačítají ani nespouštějí, dokud se VectorCraft nespustí normálně +@msg give `id` or `file` (see plugin.safeMode) zadejte `id` nebo `file` (viz plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) tento zásuvný modul není zakázán (viz plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch Zakázané zásuvné moduly nebylo možné uložit pro příští spuštění +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Rozložit vzhled: efekt zásuvného modulu ve výběru nelze v nouzovém režimu rozložit + +# Align to Pixel Grid + Align to Pixel Grid Zarovnat na mřížku obrazových bodů +@msg only paths and compound paths can be aligned to the pixel grid na mřížku obrazových bodů lze zarovnat jen cesty a složené cesty +@msg side is left or right, not `{s}` side je left nebo right, ne `{s}` diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 84134c893..395176d3d 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -135,6 +135,7 @@ Report copied Rapporten er kopieret Line {line} Linje {line} Line {line}: {name}: {status} Linje {line}: {name}: {status} + Read Action Set File Læs handlingssætfil Read Shortcut Set File Læs fil med genvejssæt Import Matches Importér match Remember Import Matches Husk importmatch @@ -172,6 +173,11 @@ Ungroup Afgruppér Lock Lås Selection Markering +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Rektangelmarkering + Touching Berørte objekter + Fully Enclosed Helt omsluttet + Drag Direction Trækretning All Artwork Above Al grafik ovenfor Other Layers Andre lag Unlock All Lås alle op @@ -293,6 +299,13 @@ Apply Bullets Anvend kugletegn Numbered List Nummereret liste Apply Numbers Anvend nummerering + Bullets and Numbering… Kugletegn og nummerering… + Numbers Tal + Level: Niveau: + Bullet Character: Kugletegn: + Number Style: Talformat: + Left Indent: Venstre indrykning: + First Line Indent: Indrykning af første linje: Convert to Text Konvertér til tekst Filled Dot Udfyldt prik Dash Tankestreg @@ -420,6 +433,17 @@ Inflate Pust op Select objects, then pick an object type to give them a 3D effect. Markér objekter, og vælg derefter en objekttype for at give dem en 3D-effekt. Extrude & Bevel… Ekstrudér og facet… + 3D (Classic) 3D (klassisk) + Extrude & Bevel (Classic)… Ekstrudér og facet (klassisk)… + Surface Overflade + Wireframe Trådmodel + No Shading Ingen skyggelægning + Plastic Plastik + Highlight Intensity Højlysintensitet + Highlight Size Højlysstørrelse + Blend Steps Overgangstrin + Shading Color Skyggefarve + Draw Hidden Faces Tegn skjulte flader Revolve… Omdrej… Inflate… Pust op… Materials… Materialer… @@ -622,6 +646,7 @@ Toolbars Værktøjslinjer Advanced Avanceret Double Column Dobbelt kolonne + Keep Tools Beside the Canvas Værktøjer ved siden af canvaset Actions Handlinger Asset Export Aktiveksport Attributes Attributter @@ -629,6 +654,11 @@ Color Farve Color Guide Farveguide Color Themes Farvetemaer + Color Wheel Farvehjul + Hue ring Ring for farvetone + Saturation and brightness Mætning og lysstyrke + Color field Farvefelt + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties CSS-egenskaber Document Info Dokumentoplysninger Flattener Preview Udjævningsforhåndsvisning @@ -815,6 +845,9 @@ Stained Glass… Blyindfattede ruder… Texturizer… Teksturér… System Title Bar Systemtitellinje + VectorCraft Title Bar on Every Desktop VectorCrafts titellinje på alle skriveborde + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Miljøvariablen VECTORCRAFT_TITLEBAR vælger titellinjen, så længe den er angivet. + System Title Bar takes precedence over this option while it is on. Systemtitellinje har forrang for denne indstilling, så længe den er slået til. Exit Afslut Distort Forvræng Diffuse Glow… Diffus glød… @@ -1171,7 +1204,7 @@ lighting Light Lys X: X: Y: Y: Drag to float the toolbar Træk for at frigøre værktøjslinjen - Drag to the window's left edge to dock Træk til vinduets venstre kant for at fastgøre + Drag to the window's left or right edge to dock Træk til vinduets venstre eller højre kant for at fastgøre Crystallize Krystallisér Add Anchor Point Tool Tilføj ankerpunkt-værktøj Anchor Point Tool Ankerpunktværktøj @@ -1425,6 +1458,15 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Indstillinger for Tate-chu-yoko… Tate-chu-yoko Settings Indstillinger for Tate-chu-yoko + Composite Fonts… Sammensatte skrifttyper… + Composite Fonts Sammensatte skrifttyper + Export Composite Fonts Eksportér sammensatte skrifttyper + Import Composite Fonts Importér sammensatte skrifttyper + Delete Font Slet skrifttype + Kanji Kanji + Kana Kana + Numerals Tal + Scale at Center Skalér fra midten Up/Down: Op/ned: Left/Right: Venstre/højre: Underline Understreget @@ -1701,6 +1743,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 @@ -2461,6 +2507,7 @@ digits Malayalam Malayalam Tile Range: Fliseinterval: tile {number} flise {number} Tiles are numbered across, then down; leave the range empty for every tile. Fliser nummereres på tværs og derefter nedad; lad intervallet stå tomt for alle fliser. + Copies per Sheet (as many as fit, in a grid) Kopier pr. ark (så mange der er plads til, i et gitter) Transverse (the page a quarter turn on the paper) På tværs (siden drejes en kvart omgang på papiret) Unprintable margin: Ikke-udskrivbar margen: {pages} in all, {count} per copy {pages} i alt, {count} pr. kopi @@ -2531,6 +2578,10 @@ digits Malayalam Malayalam < 1 sec < 1 sek [Unnamed] [Unavngiven] Amount: Mængde: + Anti-aliasing Method Antialiaseringsmetode +antiAlias Sharp Skarp + Crisp Klar + Strong Kraftig Anti-aliasing: Antialiasering: Artboard: Tegnebræt: Color Table Farvetabel @@ -2925,6 +2976,9 @@ theme Appearance Udseende Asset Export Settings Indstillinger for eksport af aktiver Assets Aktiver Export Assets Eksportér aktiver + Export 3D Object Eksportér 3D-objekt + 3D Export Format 3D-eksportformat + Export 3D Assets Eksportér 3D-aktiver Remove Asset Fjern aktiv Rename Asset Omdøb aktiv Fill Rule Udfyldningsregel @@ -3058,6 +3112,7 @@ theme Appearance Udseende Document JSON Dokument-JSON Duplicate Duplikér Find Objects Find objekter + Compare with Image Sammenlign med billede Inspect Document Inspicér dokument Inspect Object Inspicér objekt Begin Undo Group Start fortrydelsesgruppe @@ -3361,6 +3416,7 @@ theme Appearance Udseende Feet Fod Files Filer Fill New Type Objects With Placeholder Text Udfyld nye tekstobjekter med pladsholdertekst + Start Lists From Typed Markers Start lister fra indtastede markører Folder Mappe Font Preview Size Størrelse på skrifttypeforhåndsvisning Get Preferences Hent indstillinger @@ -3471,6 +3527,7 @@ theme Appearance Udseende Yards Yards Zoom to Selection Zoom til markering Zoom with Mouse Wheel Zoom med musehjul + Scroll Sideways with Cmd/Ctrl + Wheel Rul vandret med Cmd/Ctrl + musehjul Scrub Numeric Fields by Dragging Juster numeriske felter ved at trække Linux Display Server Linux-visningsserver Print Preview Udskriftsvisning @@ -3750,6 +3807,7 @@ effect Regular Regular Find in Folder… Find i mappe… Replace With Font Erstat med skrifttype Replace With Font From Erstat med skrifttype fra + Find In Søg i System System — substituted by {style} — erstattet af {style} — {n} characters from another font — {n} tegn fra en anden skrifttype @@ -3993,7 +4051,7 @@ imageTrace Outline Kontur @plural {n} stroke|{n} strokes {n} kontur|{n} konturer @msg no library `{id}` intet bibliotek `{id}` @msg select some type first markér først noget tekst -@msg kerning must be a number, "auto", or "optical" Kerning skal være et tal, „auto“ eller „optical“ +@msg kerning must be a number, "auto", or "optical" kerning skal være et tal, „auto“ eller „optical“ @msg {_1} has no {_2} style {_1} har ingen {_2}-stil @msg a layer couldn't be read et lag kunne ikke læses @msg it has more than one artboard den har mere end én tegneflade @@ -4078,6 +4136,7 @@ imageTrace Outline Kontur @msg Split Into Grid: select paths large enough for the grid Opdel i gitter: markér kurver, der er store nok til gitteret @msg Targa files are RGB: colorModel rgb Targa-filer er RGB: colorModel rgb @msg Text Wrap: select a wrap object Tekstombrydning: markér et ombrydningsobjekt +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` skal være "none", "sharp", "crisp" eller "strong" @msg `aspect` must be a number (%) `aspect` skal være et tal (%) @msg `at` must be [x, y] `at` skal være [x, y] @msg `color` must be a colour `color` skal være en farve @@ -4168,7 +4227,7 @@ imageTrace Outline Kontur @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art eller type @msg artboard {0} does not exist tegneflade {0} findes ikke @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard ved denne skala er tegningen {w} × {h} pt, mere end lærredet rummer: vælg en mindre skala, eller tilpas den til tegnefladen -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` aksen skal være "vertical", "horizontal" eller en vinkel i grader, ikke `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis skal være "vertical", "horizontal" eller en vinkel i grader, ikke `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" baggrund {other}: gennemsigtig, hvid, sort eller en farve som "#ff8800" @msg bad color {v} ugyldig farve {v} @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio skal være et tal fra 0,1 til 10 @@ -4179,7 +4238,7 @@ imageTrace Outline Kontur @msg bad trailer ugyldig trailer @msg balance must be between 0 and 100 balance skal være mellem 0 og 100 @msg batch step {_1} (`{id}`) failed: {e} batchtrin {_1} (`{id}`) mislykkedes: {e} -@msg brightness must be dark|mediumDark|mediumLight|light lysstyrken skal være dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light brightness skal være dark|mediumDark|mediumLight|light @msg can't encode the document: {0} kan ikke kode dokumentet: {0} @msg can't parse shortcut `{c}` kan ikke parse genvejen `{c}` @msg can't parse shortcut `{s}` for `{k}` kan ikke parse genvejen `{s}` for `{k}` @@ -4202,7 +4261,7 @@ imageTrace Outline Kontur @msg compound paths can only contain paths sammensatte kurver kan kun indeholde kurver @msg compressed (RLE) bitmaps can't be read yet komprimerede (RLE) bitmaps kan endnu ikke læses @msg damaged beskadiget -@msg decimals must be {_1}–{_2} (got {_3}) decimaler skal være {_1}–{_2} (fik {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals skal være {_1}–{_2} (fik {_3}) @msg divide into 1 to {MAX_PIECES} slices opdel i 1 til {MAX_PIECES} udsnit @msg effect {k} is `{_1}`, not `{id}` effekten {k} er `{_1}`, ikke `{id}` @msg empty brush stroke tomt penselstrøg @@ -4219,8 +4278,8 @@ imageTrace Outline Kontur @msg an image has no pixels and was left out et billede uden pixels blev udeladt @msg image data `{_1}` is missing billeddata `{_1}` mangler @msg image would be too large; lower the resolution billedet ville blive for stort; sænk opløsningen -@msg index must be a number or first, previous, next or last indeks skal være et tal eller first, previous, next eller last -@msg index must be a number or first, previous, next or last, not `{other}` indeks skal være et tal eller first, previous, next eller last, ikke `{other}` +@msg index must be a number or first, previous, next or last index skal være et tal eller first, previous, next eller last +@msg index must be a number or first, previous, next or last, not `{other}` index skal være et tal eller first, previous, next eller last, ikke `{other}` @msg installing from a path is not available on the web; pass the module as `dataBase64` installation fra en sti er ikke tilgængelig på webbet; overfør modulet som `dataBase64` @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) intern fejl i `{cmd}`: {msg} (dokumentet blev efterladt, som det var tidligere; meld venligst denne fejl) @msg internal error: {msg} (please report this bug) intern fejl: {msg} (meld venligst denne fejl) @@ -4318,6 +4377,8 @@ imageTrace Outline Kontur @msg no such action set intet sådant handlingssæt @msg the action sets changed; nothing was deleted handlingssættene er ændret; intet blev slettet @msg no action set to save intet handlingssæt at gemme +@msg the action set file can't be read in full handlingssætfilen kan ikke læses helt +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Indlæste »{name}«: {n} trin blev udeladt|Indlæste »{name}«: {n} trin blev udeladt @msg not an action set file ikke en handlingssætfil @msg the action set file is larger than 8 MiB handlingssætfilen er større end 8 MiB @msg the action set file holds more than 1,000 actions handlingssætfilen indeholder mere end 1.000 handlinger @@ -4383,9 +4444,9 @@ imageTrace Outline Kontur @msg replace: the object to replace was changed or deleted erstat: objektet, der skulle erstattes, er ændret eller slettet @msg pattern missing mønster mangler @msg patterns nested too deeply are filled with mid-grey mønstre, der er indlejret for dybt, udfyldes med mellemgrå -@msg percent must be above 0 and at most 6400, not {p} procent skal være over 0 og højst 6400, ikke {p} +@msg percent must be above 0 and at most 6400, not {p} percent skal være over 0 og højst 6400, ikke {p} @msg pick a colour, gradient or pattern to save as a swatch vælg en farve, en gradient eller et mønster, der skal gemmes som en farveprøve -@msg plane must be left, right or ground planet skal være venstre, højre eller grund +@msg plane must be left, right or ground plane skal være left, right eller ground @msg plug-in ABI: {0} plug-in-ABI: {0} @msg plug-in exceeded its {0} plug-in'et overskred sin {0} @msg plug-in failed: {0} plug-in fejlede: {0} @@ -4404,8 +4465,8 @@ imageTrace Outline Kontur @msg result would exceed the canvas (coordinates out of range) resultatet ville gå ud over lærredet (koordinater uden for intervallet) @msg objects can't be nested more than 32 deep objekter kan ikke indlejres mere end 32 niveauer dybt @msg the SVG's elements are nested more than {0} deep SVG'ens elementer er indlejret mere end {0} niveauer dybt -@msg scale must be a number from {_1} to {_2}, not {_3} skalaen skal være et tal fra {_1} til {_2}, ikke {_3} -@msg scale must be a number from {_1} to {_2}, not {s} skalaen skal være et tal fra {_1} til {_2}, ikke {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale skal være et tal fra {_1} til {_2}, ikke {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale skal være et tal fra {_1} til {_2}, ikke {s} @msg select (or target) objects whose appearance isn't basic markér (eller målret) objekter, hvis udseende ikke er grundlæggende @msg select a Live Paint group markér en Live Paint-gruppe @msg select a blend markér en blanding @@ -4645,10 +4706,14 @@ imageTrace Outline Kontur @msg its objects are nested too deeply dens objekter er indlejret for dybt @msg its section ending with `%{end}` doesn't end dens sektion, der ender med `%{end}`, ender ikke @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) pensler, som VectorCraft endnu ikke kan læse, blev udeladt af panelet Pensler ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekter med flere fyld eller streger, effekter eller pensler kom ind, som de er tegnet ({_1}): deres udseende er endnu ikke redigerbart +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekter med flere fyld eller streger, streger med variabel bredde, effekter eller pensler kom ind, som de er tegnet ({_1}): deres udseende er endnu ikke redigerbart +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding navnet `{name}` er ikke UTF-8 og blev læst som {label}: hvis det ser forkert ud, så åbn filen med en anden legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding navnet `{name}` er ikke UTF-8, og dele af det er heller ikke gyldig {label}: åbn filen med en anden legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) flere navne, der ikke er UTF-8, blev læst som {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradienten »{name}« er ikke defineret i filen: der bruges en flad farve i stedet @msg an image VectorCraft can't read was left out et billede, som VectorCraft ikke kan læse, blev udeladt @msg an image whose data is cut short was left out et billede, hvis data er afkortet, blev udeladt +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers et indlejret billede på {_1} × {_2} pixel blev formindsket til {_3} × {_4} pixel for at bevare lagene @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel linkede billeder viser den gemte forhåndsvisning i lav opløsning, indtil deres filer findes: link dem igen i Links-panelet @msg linked type areas past VectorCraft's limits were left out sammenkædede tekstområder ud over VectorCrafts grænser blev udeladt @msg type that changes writing direction inside a text object opens in the direction of its first character tekst, der skifter skriveretning inde i et tekstobjekt, åbnes i retningen for sit første tegn @@ -4684,6 +4749,7 @@ imageTrace Outline Kontur @msg object ids are non-negative integers, not {v} objekt-id'er er ikke-negative heltal, ikke {v} @msg {key} must be an array of non-negative integer object ids {key} skal være et array af objekt-id'er, der er ikke-negative heltal @msg invalid object id {value}: expected a non-negative integer ugyldigt objekt-id {value}: et ikke-negativt heltal var forventet +@msg `{key}` entry {index} is {value}, not a non-negative integer object id element {index} i `{key}` er {value}, ikke et objekt-id (et ikke-negativt heltal) @msg no such object id {value} der findes intet objekt med id {value} @msg automation filesystem access is not granted: read authority is absent: {path} automatiseringens adgang til filsystemet er ikke tildelt: læserettighed mangler: {path} @msg automation filesystem access is not granted: write authority is absent: {path} automatiseringens adgang til filsystemet er ikke tildelt: skriverettighed mangler: {path} @@ -4713,7 +4779,7 @@ imageTrace Outline Kontur @msg before must name a panel before skal angive et panel @msg docking action contains an unknown panel or an excessive split path dokningshandlingen indeholder et ukendt panel eller en overdreven opdelingssti @msg group must be true or false group skal være true eller false -@msg panel is not in a dock group panelet er ikke i en dockgruppe +@msg panel is not in a dock group panel er ikke i en dockgruppe @msg only a dock group above or below others collapses, not one beside another kun en dockgruppe over eller under andre kan skjules, ikke en ved siden af en anden @msg icon must be true or false icon skal være true eller false @msg icon only docks a panel in the icon column, with no anchor icon docker kun et panel i ikonkolonnen, uden anchor @@ -5725,6 +5791,8 @@ axis Y Axis Y-akse Install “{name}”, which asks for these permissions? Installér »{name}«, som beder om disse rettigheder? ID: {id} ID: {id} Version: {version} Version: {version} + Install “{name}” in place of the plug-in with the same ID? Installér »{name}« i stedet for det plug-in, der har samme id? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Denne fil er ikke det plug-in, der før var installeret med dette id. Den får de indstillinger og den cache, der er gemt for dette id. File SHA-256: {hash} Filens SHA-256: {hash} Read the objects it is given Læse de objekter, den får Change, add and delete objects in the document Ændre, tilføje og slette objekter i dokumentet @@ -5732,6 +5800,9 @@ axis Y Axis Y-akse Change how the objects its effect is applied to are drawn Ændre, hvordan de objekter, dens effekt er anvendt på, tegnes Write the document as a file in its own format Skrive dokumentet som en fil i sit eget format Show its progress, see Cancel and write log lines Vise sin fremdrift, se en annullering og skrive logrækker + Keep settings of its own Gemme sine egne indstillinger + Keep a cache of its own, which you can clear Gemme sin egen cache, som du kan rydde + Clear Plug-in Cache Ryd plug-in-cache # Actions panel: action sets (#3712). New Set… Nyt sæt… New Set Nyt sæt @@ -6113,6 +6184,8 @@ breakText Glyphs Glyffer File + Data Set Name Fil + datasætnavn Data Set Name Datasætnavn @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` skal være `fileNumber`, `fileDataSetName` eller `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Hvert datasæt gemmes oven i den samme fil, som kun beholder det sidste datasæt. +@msg save the document with Save As before Save and Close gem dokumentet med Gem som før Gem og luk @msg no free data set file name in destination folder Intet ledigt filnavn til datasættet i destinationsmappen # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6232,9 +6305,20 @@ breakText Glyphs Glyffer Reset Symbol Instance Nulstil symbolforekomst @msg type must be static or dynamic, not {v} type skal være static eller dynamic, ikke {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` er et statisk symbol: indstil først symboltypen til Dynamisk +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change For en kurve, sammensat kurve eller tekst, der er markeret i en symbolforekomst, kan kun ensfarvet fyld og streg ændres @msg path must be a list of child indices path skal være en liste over underordnede indekser @msg no object at path {shown} in the symbol's art intet objekt ved stien {shown} i symbolets grafik @msg give fill, stroke, opacity and/or blend angiv fill, stroke, opacity og/eller blend +@msg {key} cannot be set on a guide {key} kan ikke angives for en hjælpelinje +@msg {key} cannot be set on a blend {key} kan ikke angives for en blanding +@msg {key} cannot be set on an envelope {key} kan ikke angives for en kuvert +@msg {key} cannot be set on a gradient mesh {key} kan ikke angives for et gradientnet +@msg {key} cannot be set on a Repeat {key} kan ikke angives for en gentagelse +@msg {key} cannot be set on a placed document {key} kan ikke angives for et placeret dokument +@msg {key} cannot be set on an Intertwine group {key} kan ikke angives for en sammenflettet gruppe +@msg stroke cannot be set on a symbol instance stroke kan ikke angives for en symbolforekomst +@msg fill on a symbol instance must be a solid color or none fill på en symbolforekomst skal være en ensfarvet farve eller none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} kan ikke angives for et billede uden effekten Konvertér til form eller Objekt til kontur @msg pass newName, registration, exportType, nineSlice and/or type angiv newName, registration, exportType, nineSlice og/eller type Text Flow Tekstflow By Rows Efter rækker @@ -6264,6 +6348,7 @@ breakText Glyphs Glyffer Create Swatch Legend Opret farveprøveforklaring Create Swatch Legend… Opret farveprøveforklaring… HEX HEX + Help Bar Hjælpelinje # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Tekstværktøjer Text Tweaker… Tekstjustering… @@ -6329,3 +6414,30 @@ 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 + +# Safe Mode + Safe Mode… Sikker tilstand… + Safe Mode Sikker tilstand + Help › Safe Mode… Hjælp › Sikker tilstand… + Enable on Re-launch Aktivér ved næste start + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Sidste start af VectorCraft stoppede, mens plug-in'et {name} blev indlæst. Det er måske ikke årsagen. + Disable and Continue Deaktiver og fortsæt + Start Normally Start normalt + Start in Safe Mode Start i sikker tilstand + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft er i sikker tilstand: ingen plug-ins indlæses eller køres, før programmet startes normalt. + Disabled plug-ins Deaktiverede plug-ins + No plug-ins are disabled. Ingen plug-ins er deaktiveret. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Sikker tilstand er slået til: plug-ins indlæses og køres ikke, før VectorCraft startes normalt +@msg give `id` or `file` (see plugin.safeMode) angiv `id` eller `file` (se plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) det plug-in er ikke deaktiveret (se plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch De deaktiverede plug-ins kunne ikke gemmes til næste start +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Udvid udseende: en plug-in-effekt i markeringen kan ikke udvides i sikker tilstand + +# Align to Pixel Grid + Align to Pixel Grid Juster efter pixelgitter +@msg side is left or right, not `{s}` side er left eller right, ikke `{s}` diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7d0e48fb8..db16fec6a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -173,6 +173,10 @@ Anti-Alias Glätten Anti-alias Rasters Pixelbilder glätten Anti-aliased Artwork Geglättete Grafik + Anti-aliasing Method Glättungsmethode +antiAlias Sharp Scharf + Crisp Klar + Strong Stark Anti-aliasing: Glättung: Any Kind Beliebige Art Font Technology Schrifttechnologie @@ -342,6 +346,13 @@ font-technology Variable Variabel Apply Bullets Aufzählungszeichen anwenden Numbered List Nummerierte Liste Apply Numbers Nummerierung anwenden + Bullets and Numbering… Aufzählungszeichen und Nummerierung… + Numbers Nummern + Level: Listenebene: + Bullet Character: Aufzählungszeichen: + Number Style: Nummernformat: + Left Indent: Einzug links: + First Line Indent: Einzug erste Zeile: Convert to Text In Text umwandeln Filled Dot Gefüllter Punkt Dash Strich @@ -533,6 +544,11 @@ font-technology Variable Variabel Color Table ({n} colors) Farbtabelle ({n} Farben) Color Theme Farbthema Color Themes Farbthemen + Color Wheel Farbkreis + Hue ring Farbtonring + Saturation and brightness Sättigung und Helligkeit + Color field Farbauswahlfeld + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Farbart: Color: Farbe: Colorize Einfärben @@ -1057,6 +1073,14 @@ font-technology Variable Variabel Export As SVG… Als SVG exportieren… Export Asset Asset exportieren Export Assets Assets exportieren + Export 3D Object 3D-Objekt exportieren + 3D Export Format 3D-Exportformat + Export 3D Assets 3D-Assets exportieren +@msg {what} can't be exported in 3D: {e} {what} kann nicht als 3D exportiert werden: {e} +@msg asset {id} isn't a 3D asset Asset {id} ist kein 3D-Asset +@msg asset {id} is a 3D asset: assets.export3D writes it Asset {id} ist ein 3D-Asset: assets.export3D schreibt es +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) noch keine 3D-Assets: Sammeln Sie zuerst ein Objekt mit 3D-Effekt (assets.add3D) +@msg select an object with a 3D effect (or give its ids) wählen Sie ein Objekt mit 3D-Effekt aus (oder geben Sie seine IDs an) Export As… Exportieren als… Export CSS CSS exportieren Export CSS… CSS exportieren… @@ -1088,6 +1112,17 @@ font-technology Variable Variabel Exporting {} {} wird exportiert Export… Exportieren… Extrude & Bevel… Extrudieren und abgeflachte Kante… + 3D (Classic) 3D (klassisch) + Extrude & Bevel (Classic)… Extrudieren und abgeflachte Kante (klassisch)… + Surface Oberfläche + Wireframe Drahtmodell + No Shading Keine Schattierung + Plastic Kunststoff + Highlight Intensity Glanzpunktintensität + Highlight Size Glanzpunktgröße + Blend Steps Angleichungsstufen + Shading Color Schattierungsfarbe + Draw Hidden Faces Verdeckte Flächen zeichnen Eyedropper Pipette Sample Color from Screen Farbe vom Bildschirm aufnehmen This system has no screen color sampler Dieses System hat keine Farbaufnahme vom Bildschirm @@ -1145,6 +1180,7 @@ font-technology Variable Variabel Stroke When Editable Kontur, wenn bearbeitbar Fill Color Flächenfarbe Fill New Type Objects With Placeholder Text Neue Textobjekte mit Platzhaltertext füllen + Start Lists From Typed Markers Listen aus getippten Aufzählungszeichen beginnen Fill Rule Füllregel Fill with Placeholder Text Mit Platzhaltertext füllen Fill: Fläche: @@ -1155,6 +1191,7 @@ font-technology Variable Variabel Find Font… Schrift suchen… Find Next Weitersuchen Find Objects Objekte suchen + Compare with Image Mit Bild vergleichen First Page Erste Seite First-line Left Indent Einzug links in erster Zeile Fisheye… Fischauge… @@ -1712,6 +1749,7 @@ file-filter Templates Vorlagen Line {line} Zeile {line} Line {line}: {name}: {status} Zeile {line}: {name}: {status} Imported. Press OK to keep the imported set. Importiert. Klicken Sie auf „OK“, um den importierten Satz zu behalten. + Read Action Set File Aktionssatzdatei lesen Read Shortcut Set File Tastaturbefehlssatz-Datei lesen Import Matches Import-Zuordnungen Remember Import Matches Import-Zuordnungen merken @@ -1960,6 +1998,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 @@ -2720,6 +2762,7 @@ digits Malayalam Malayalam Reset Transformation Transformation zurücksetzen Replace With Font Durch Schrift ersetzen Replace With Font From Ersetzende Schrift aus + Find In Suchen in System System Replaces the saved preset of that name. Ersetzt die gespeicherte Vorgabe dieses Namens. Replace… Ersetzen… @@ -3020,6 +3063,11 @@ digits Malayalam Malayalam Selected Swatches Ausgewählte Farbfelder Selected Swatches Only Nur ausgewählte Farbfelder Selection Auswahl +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Rechteckauswahl + Touching Berührt + Fully Enclosed Vollständig eingeschlossen + Drag Direction Ziehrichtung Selection & Anchor Display Auswahl und Ankerpunktanzeige Selection Only Nur Auswahl Selection Tool Auswahl-Werkzeug @@ -3157,6 +3205,7 @@ digits Malayalam Malayalam Simulate Colored Paper Farbiges Papier simulieren Simulate Paper Papier simulieren Double Column Zweispaltig + Keep Tools Beside the Canvas Werkzeuge neben der Arbeitsfläche Single Left Quotation Mark Einfaches öffnendes Anführungszeichen Single Quotes: Einfache Anführungszeichen: Single Right Quotation Mark Einfaches schließendes Anführungszeichen @@ -3389,6 +3438,15 @@ effect Points Punkte Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Tate-chu-yoko-Einstellungen… Tate-chu-yoko Settings Tate-chu-yoko-Einstellungen + Composite Fonts… Zusammengesetzte Schriften… + Composite Fonts Zusammengesetzte Schriften + Export Composite Fonts Zusammengesetzte Schriften exportieren + Import Composite Fonts Zusammengesetzte Schriften importieren + Delete Font Schrift löschen + Kanji Kanji + Kana Kana + Numerals Ziffern + Scale at Center Von der Mitte aus skalieren Up/Down: Oben/Unten: Left/Right: Links/Rechts: Temperature Temperatur @@ -3442,6 +3500,8 @@ effect Points Punkte Tile Type: Kacheltyp: tile {number} Kachel {number} Tiles are numbered across, then down; leave the range empty for every tile. Kacheln werden erst quer, dann nach unten nummeriert; ein leerer Bereich steht für alle Kacheln. + Copies per Sheet (as many as fit, in a grid) Kopien pro Blatt (so viele wie passen, im Raster) + Gutter: Zwischenraum: Tint Tonwert Tip extends past the end point Spitze ragt über den Endpunkt hinaus Tip on the end point Spitze auf dem Endpunkt @@ -3702,6 +3762,7 @@ effect Points Punkte Zoom to Selection Auf Auswahl zoomen Zoom Tool Zoom-Werkzeug Zoom with Mouse Wheel Mit Mausrad zoomen + Scroll Sideways with Cmd/Ctrl + Wheel Mit Cmd/Ctrl + Mausrad horizontal scrollen Scrub Numeric Fields by Dragging Zahlenfelder durch Ziehen ändern @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} Datei in {folder} {note}. Paketordner anzeigen?|{n} Dateien in {folder} {note}. Paketordner anzeigen? @plural {n} linked file not found|{n} linked files not found {n} verknüpfte Datei nicht gefunden|{n} verknüpfte Dateien nicht gefunden @@ -3876,7 +3937,7 @@ theme Appearance Erscheinungsbild Dock to Bottom Unten andocken Dock Control Bar Steuerungsleiste andocken Drag to float the toolbar Ziehen, um die Werkzeugleiste abzulösen - Drag to the window's left edge to dock Zum Andocken an den linken Fensterrand ziehen + Drag to the window's left or right edge to dock Zum Andocken an den linken oder rechten Fensterrand ziehen Float Tool Group Werkzeuggruppe ablösen # Status and error messages (`@msg`), translated where the status bar shows them; the message itself @@ -3885,7 +3946,7 @@ theme Appearance Erscheinungsbild # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` keine Bibliothek `{id}` @msg select some type first wählen Sie zuerst Text aus -@msg kerning must be a number, "auto", or "optical" Kerning muss eine Zahl, „auto“ oder „optical“ sein +@msg kerning must be a number, "auto", or "optical" kerning muss eine Zahl, „auto“ oder „optical“ sein @msg the selection has no gradient fill die Auswahl hat keinen Flächenverlauf @msg the selection has no gradient stroke die Auswahl hat keinen Konturverlauf @msg the selection has no pattern fill die Auswahl hat kein Flächenmuster @@ -3989,6 +4050,7 @@ theme Appearance Erscheinungsbild @msg Targa files are RGB: colorModel rgb Targa-Dateien sind RGB: colorModel rgb @msg Text Wrap: select a wrap object Textumfluss: Wählen Sie ein Umflussobjekt aus @msg This shortcut file is in a form VectorCraft can't read. Diese Tastaturbefehlsdatei liegt in einem Format vor, das VectorCraft nicht lesen kann. +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` muss "none", "sharp", "crisp" oder "strong" sein @msg `aspect` must be a number (%) `aspect` muss eine Zahl sein (%) @msg `at` must be [x, y] `at` muss [x, y] sein @msg `color` must be a colour `color` muss eine Farbe sein @@ -4265,6 +4327,8 @@ theme Appearance Erscheinungsbild @msg no such action set kein solcher Aktionssatz @msg the action sets changed; nothing was deleted die Aktionssätze haben sich geändert; nichts wurde gelöscht @msg no action set to save kein Aktionssatz zum Speichern +@msg the action set file can't be read in full die Aktionssatzdatei kann nicht vollständig gelesen werden +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}“ geladen: {n} Schritt wurde ausgelassen|„{name}“ geladen: {n} Schritte wurden ausgelassen @msg not an action set file keine Aktionssatzdatei @msg the action set file is larger than 8 MiB die Aktionssatzdatei ist größer als 8 MiB @msg the action set file holds more than 1,000 actions die Aktionssatzdatei enthält mehr als 1.000 Aktionen @@ -4389,6 +4453,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 @@ -4626,11 +4697,15 @@ theme Appearance Erscheinungsbild @msg its objects are nested too deeply seine Objekte sind zu tief verschachtelt @msg its section ending with `%{end}` doesn't end sein Abschnitt, der mit `%{end}` enden soll, endet nicht @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) Pinsel, die VectorCraft noch nicht lesen kann, wurden im Bedienfeld „Pinsel“ ausgelassen ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet Objekte mit mehreren Flächen oder Konturen, Effekten oder Pinseln wurden so übernommen, wie sie gezeichnet aussehen ({_1}): Ihr Aussehen ist noch nicht bearbeitbar +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet Objekte mit mehreren Flächen oder Konturen, Konturen mit variabler Breite, Effekten oder Pinseln wurden so übernommen, wie sie gezeichnet aussehen ({_1}): Ihr Aussehen ist noch nicht bearbeitbar +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding Der Name `{name}` ist kein UTF-8 und wurde als {label} gelesen: Wenn er falsch aussieht, öffnen Sie die Datei mit einer anderen legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding Der Name `{name}` ist kein UTF-8, und Teile davon sind auch kein gültiges {label}: Öffnen Sie die Datei mit einer anderen legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) Weitere Namen, die kein UTF-8 sind, wurden als {label} gelesen ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it der Verlauf „{name}“ ist in der Datei nicht definiert: Eine einfache Farbe steht an seiner Stelle @msg an image VectorCraft can't read was left out ein Bild, das VectorCraft nicht lesen kann, wurde ausgelassen @msg graphs come in as plain art, without their data or settings Diagramme werden als einfache Grafik übernommen, ohne ihre Daten und Einstellungen @msg an image whose data is cut short was left out ein Bild mit abgeschnittenen Daten wurde ausgelassen +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers ein eingebettetes Bild mit {_1} × {_2} Pixeln wurde auf {_3} × {_4} Pixel verkleinert, damit die Ebenen erhalten bleiben @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel verknüpfte Bilder zeigen die in der Datei gespeicherte Vorschau in niedriger Auflösung, bis ihre Dateien gefunden werden: Verknüpfen Sie sie im Bedienfeld „Verknüpfungen“ neu @msg linked type areas past VectorCraft's limits were left out verkettete Textbereiche jenseits der Grenzen von VectorCraft wurden ausgelassen @msg type that changes writing direction inside a text object opens in the direction of its first character Text, der innerhalb eines Textobjekts die Schreibrichtung wechselt, wird in der Richtung seines ersten Zeichens geöffnet @@ -4713,6 +4788,7 @@ theme Appearance Erscheinungsbild @msg object ids are non-negative integers, not {v} Objekt-IDs sind nicht negative ganze Zahlen, nicht {v} @msg {key} must be an array of non-negative integer object ids {key} muss ein Array von Objekt-IDs sein, die nicht negative ganze Zahlen sind @msg invalid object id {value}: expected a non-negative integer ungültige Objekt-ID {value}: erwartet wird eine nicht negative ganze Zahl +@msg `{key}` entry {index} is {value}, not a non-negative integer object id Eintrag {index} von `{key}` ist {value}, keine Objekt-ID (nicht negative ganze Zahl) @msg no such object id {value} kein Objekt mit der ID {value} @msg id {value} is a layer, not an object die ID {value} ist eine Ebene, kein Objekt @msg id {value} is a layer, not an object; move layers with layer.move die ID {value} ist eine Ebene, kein Objekt; verschieben Sie Ebenen mit layer.move @@ -4762,6 +4838,9 @@ theme Appearance Erscheinungsbild @plural {n} linked image changed on disk. Show the new version?|{n} linked images changed on disk. Show the new versions? {n} verknüpftes Bild wurde auf dem Datenträger geändert. Neue Version anzeigen?|{n} verknüpfte Bilder wurden auf dem Datenträger geändert. Neue Versionen anzeigen? # Window: the system's title bar (Windows, Linux). System Title Bar Systemtitelleiste + VectorCraft Title Bar on Every Desktop VectorCraft-Titelleiste auf jedem Desktop + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Solange die Umgebungsvariable VECTORCRAFT_TITLEBAR gesetzt ist, bestimmt sie die Titelleiste. + System Title Bar takes precedence over this option while it is on. Solange Systemtitelleiste eingeschaltet ist, hat sie Vorrang vor dieser Option. # Brush Options: Scatter, Art and Pattern brushes. Scatter Brush Options Optionen für Spezialpinsel Art Brush Options Optionen für Bildpinsel @@ -4823,9 +4902,16 @@ theme Appearance Erscheinungsbild @msg the Pixar file's image data is damaged die Bilddaten der Pixar-Datei sind beschädigt @msg the JPEG 2000 file can't be read die JPEG-2000-Datei kann nicht gelesen werden @msg the JPEG 2000 file has an unsupported color space or JPX content die JPEG-2000-Datei enthält einen nicht unterstützten Farbraum oder JPX-Inhalt +@msg the HEIC or HEIF file can't be read die HEIC- oder HEIF-Datei kann nicht gelesen werden +@msg the HEIC or HEIF file holds a kind of image that can't be read die HEIC- oder HEIF-Datei enthält eine Bildart, die nicht gelesen werden kann +@msg HEIC and HEIF images can't be opened in this build HEIC- und HEIF-Bilder können in diesem Build nicht geöffnet werden @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 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 On Left Side Links On Right Side Rechts @@ -4903,12 +4989,12 @@ theme Appearance Erscheinungsbild Resize panel window Bedienfeldfenster skalieren Resize panels Bedienfelder skalieren @msg Invalid panel layout; restored the default dock Ungültige Bedienfeldanordnung; Standarddock wiederhergestellt -@msg action is required Eine Aktion ist erforderlich -@msg anchor must name a registered panel Der Anker muss ein registriertes Bedienfeld benennen +@msg action is required action ist erforderlich +@msg anchor must name a registered panel anchor muss ein registriertes Bedienfeld benennen @msg before must name a panel before muss ein Bedienfeld benennen @msg docking action contains an unknown panel or an excessive split path Die Docking-Aktion enthält ein unbekanntes Bedienfeld oder einen zu langen Teilungspfad @msg group must be true or false group muss true oder false sein -@msg panel is not in a dock group Bedienfeld ist in keiner Dock-Gruppe +@msg panel is not in a dock group panel ist in keiner Dock-Gruppe @msg only a dock group above or below others collapses, not one beside another nur eine Dock-Gruppe über oder unter anderen lässt sich minimieren, nicht eine daneben @msg icon must be true or false icon muss true oder false sein @msg icon only docks a panel in the icon column, with no anchor icon dockt ein Bedienfeld nur in der Symbolspalte an, ohne anchor @@ -5078,6 +5164,11 @@ lighting Light Licht @msg unknown parameter `{key}`; the command takes: {keys} unbekannter Parameter `{key}`; der Befehl akzeptiert: {keys} Load EPS into Clipboard EPS in die Zwischenablage laden @msg an image of {w} × {h} pixels is too large to draw ein Bild mit {w} × {h} Pixeln ist zu groß, um gezeichnet zu werden +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: kein PNG-, JPEG-, WebP-, TIFF- oder BMP-Bild +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ das Bild hat {iw} × {ih} Pixel und die Zeichenfläche in dieser Breite {rw} × {rh} Pixel: ihre Proportionen unterscheiden sich +@msg `threshold` must be a number from 0 to 1 `threshold` muss eine Zahl von 0 bis 1 sein +@msg background {v}: a color such as "#808080" background {v}: eine Farbe wie "#808080" +@msg comparing with an image file isn't available in the browser der Vergleich mit einer Bilddatei ist im Browser nicht verfügbar @msg an image of {w} × {h} pixels is shown at reduced size ein Bild mit {w} × {h} Pixeln wird verkleinert angezeigt @msg the image can't be read as a texture das Bild kann nicht als Struktur gelesen werden Basic Basis @@ -5284,11 +5375,19 @@ effect Soft Weich @msg too many library panels were opened in this session in dieser Sitzung wurden zu viele Bibliotheksbedienfelder geöffnet @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Das Bild hat {_1} × {_2} Pixel, mehr als {LARGE_TRACE_SIDE} auf einer Seite: Es wird nachgezeichnet, aber langsamer. Eine Vorgabe mit geringerer Detailtreue oder eine kleinere Kopie des Bildes geht schneller. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Das Bild wurde mit {_1} × {_2} Pixeln nachgezeichnet, verkleinert von {_3} × {_4} (höchstens {_5} Megapixel). +@msg byColor must be true or false byColor muss true oder false sein +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor kann Gruppen aus Automatisch gruppieren nicht aufteilen: ohne Automatisch gruppieren nachzeichnen +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor kann eine Nachzeichnung mit einem aktiven Pathfinder-Effekt nicht neu gruppieren: ohne byColor umwandeln Snapping Einrasten Snap to Tangent An Tangente ausrichten Snap to Perpendicular An Senkrechter ausrichten @msg unknown snap option `{key}` (use {options}) unbekannte Einrastoption `{key}` (möglich: {options}) Gap Length: Lückenlänge: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Der Modus „Flaches Logo“ verwendet keepColors nicht: Er zeichnet die in logoColors genannten Farben nach oder die Farben, die er findet. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Nichts wurde nachgezeichnet: Keine der beim Nachzeichnen gefundenen Farben entspricht keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Nichts wurde nachgezeichnet: „Farbe ignorieren“ lässt jeden Bereich der in keepColors aufgeführten Farben aus. +@msg Nothing was traced: the colors that match keepColors produce no paths. Nichts wurde nachgezeichnet: Die Farben, die keepColors entsprechen, ergeben keine Pfade. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: Keine der beim Nachzeichnen gefundenen Farben entspricht {c}, daher wird davon nichts nachgezeichnet. @msg `colorGroup` needs `library: "document"` `colorGroup` erfordert `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` erfordert `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` erfordert `mode: "color"` @@ -5403,11 +5502,14 @@ axis Y Axis Y-Achse @msg Running “{name}”… „{name}“ wird ausgeführt… @msg a live effect runs while the document is drawn, so it can't be `heavy` ein Live-Effekt läuft, während das Dokument gezeichnet wird, daher kann er nicht `heavy` sein @msg an exporter always gets an export's budget: leave `budget` out ein Export-Plug-in erhält immer das Budget eines Exports: Lassen Sie `budget` weg -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ verlangt ein großes Rechenbudget (jede Ausführung bis zu {minutes} Minuten und {times}-mal so viele Anweisungen wie üblich): Installieren Sie es mit `allowHeavy: true`, sobald der Benutzer zustimmt +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}“ verlangt ein großes Rechenbudget (jede Ausführung bis zu {minutes} Minuten und {times}-mal so viele Anweisungen wie üblich): Eine Person muss es in der Desktop-App genehmigen, oder übergeben Sie `allowHeavy: true` in einer mit --allow-plugin-grants gestarteten Sitzung # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? „{name}“ installieren, das diese Berechtigungen verlangt? ID: {id} ID: {id} Version: {version} Version: {version} + Install “{name}” in place of the plug-in with the same ID? „{name}“ anstelle des Plug-ins mit derselben ID installieren? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Diese Datei ist nicht das Plug-in, das vorher mit dieser ID installiert war. Sie erhält die Einstellungen und den Cache, die für diese ID gespeichert sind. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}“ ist ein anderes Modul als das Plug-in mit der ID {_1} und würde die dafür gespeicherten Einstellungen und den Cache erhalten: Installieren Sie es mit `replace: true`, sobald der Benutzer zustimmt File SHA-256: {hash} Datei-SHA-256: {hash} Read the objects it is given Die Objekte lesen, die es erhält Change, add and delete objects in the document Objekte im Dokument ändern, hinzufügen und löschen @@ -5415,21 +5517,43 @@ 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 Show its progress, see Cancel and write log lines Den Fortschritt anzeigen, ein Abbrechen erkennen und Protokollzeilen schreiben -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} die Berechtigung `progress` braucht die Plug-in-API-Stufe {PROGRESS_API}: Geben Sie `api` {PROGRESS_API} an + Keep settings of its own Eigene Einstellungen speichern + Keep a cache of its own, which you can clear Einen eigenen Cache anlegen, den Sie leeren können + Clear Plug-in Cache Plug-in-Cache leeren +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} die Berechtigung `{permission}` braucht die Plug-in-API-Stufe {PROGRESS_API}: Geben Sie `api` {PROGRESS_API} an @msg the module imports `env.{name}` with another type than the host's: it is {signature} das Modul importiert `env.{name}` mit einem anderen Typ als dem des Hosts: Der Typ des Hosts ist {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` das Modul importiert `env.{name}`, das ein Plug-in nur auf der Plug-in-API-Stufe {PROGRESS_API} (`api` {PROGRESS_API}) und mit der Berechtigung `progress` verwenden darf -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network das Modul importiert `{from}.{name}`, das der Host nicht anbietet: Ein Plug-in darf nur `env.vc_progress`, `env.vc_cancelled` und `env.vc_log` importieren (Plug-in-API-Stufe 2), aber kein WASI, keine Dateien und kein Netzwerk +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` das Modul importiert `env.{name}`, das ein Plug-in nur auf der Plug-in-API-Stufe {PROGRESS_API} (`api` {PROGRESS_API}) und mit der Berechtigung `{permission}` verwenden darf +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network das Modul importiert `{from}.{name}`, das der Host nicht anbietet: Ein Plug-in darf nur die `env.vc_*`-Funktionen importieren, die docs/plugins.md aufführt (Plug-in-API-Stufe 2), aber kein WASI, keine Dateien und kein Netzwerk @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` may only be true `paintOutput` darf nur true sein +@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}` @msg at most {MAX_PERMISSIONS} `permissions` höchstens {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` die Berechtigung `{shown}` gilt nicht für ein Plug-in der Art `{_1}` @msg permission `{shown}` is listed twice die Berechtigung `{shown}` ist doppelt aufgeführt +@msg only a filter declares `inputs` nur ein Filter deklariert `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` nennt eine unbekannte Objektart `{shown}`: verwenden Sie type, image oder symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` führt `{shown}` doppelt auf +@msg `inputs` needs `api` 2 or later `inputs` erfordert `api` 2 oder höher +@msg `inputs` needs the permission `doc.read` `inputs` erfordert die Berechtigung `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) Speicherbudget (Objekt {_1} benötigt {size} Byte Eingabe, {left} verbleiben) +@msg object budget (more than {MAX_OBJECTS} read-only objects) Objektbudget (mehr als {MAX_OBJECTS} schreibgeschützte Objekte) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it die Berechtigung `{shown}` ist für eine spätere Plug-in-API reserviert; dieser Host gewährt sie nicht @msg unknown permission `{shown}` unbekannte Berechtigung `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ verlangt die Berechtigungen {asked}: Installieren Sie es mit `grant`, das sie aufführt, sobald der Benutzer zustimmt +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants „{name}“ verlangt die Berechtigungen {asked}: Eine Person muss sie in der Desktop-App genehmigen, oder übergeben Sie `grant` in einer mit --allow-plugin-grants gestarteten Sitzung +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` und `allowHeavy` wirken nur in einer mit --allow-plugin-grants gestarteten Sitzung: Genehmigen Sie das Plug-in in der Desktop-App +@msg Only the person at the keyboard can approve installing a plug-in Nur die Person an der Tastatur kann die Installation eines Plug-ins genehmigen @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“ wurde `{p}` nicht gewährt, das seine Ausgabe braucht: Es wurde nichts geändert +@msg a heavy filter runs in the background, so it can't declare `commands` ein schwerer Filter läuft im Hintergrund und kann daher keine `commands` deklarieren +@msg at most {MAX_COMMANDS} `commands` höchstens {MAX_COMMANDS} `commands` +@msg command {name} is declared twice der Befehl {name} ist doppelt deklariert +@msg command {name}: `label` must be 1-64 printable characters Befehl {name}: `label` muss 1-64 druckbare Zeichen haben +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter Befehl {name}: Verwenden Sie eine ID aus 1-{MAX_COMMAND_ID} Zeichen aus a-z 0-9 _, die mit einem Buchstaben beginnt +@msg command {name}: {e} Befehl {name}: {e} +@msg only a filter or a live effect declares `commands` nur ein Filter oder ein Live-Effekt deklariert `commands` +@msg “{name}” › {label}: {e} „{name}“ › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed das Modul hat nicht den SHA-256-Wert aus `hash`: Es wurde nichts installiert # Actions panel: action sets (#3712). New Set… Neuer Satz… @@ -5850,6 +5974,8 @@ breakText Glyphs Glyphen File + Data Set Name Datei + Datensatzname Data Set Name Datensatzname @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` muss `fileNumber`, `fileDataSetName` oder `dataSetName` sein + Each data set saves over the same file, which keeps only the last data set. Jeder Datensatz wird über dieselbe Datei gespeichert, die nur den letzten Datensatz behält. +@msg save the document with Save As before Save and Close speichern Sie das Dokument mit „Speichern unter“, bevor Sie „Speichern und schließen“ wählen @msg no free data set file name in destination folder Kein freier Dateiname für den Datensatz im Zielordner # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -5970,9 +6096,20 @@ breakText Glyphs Glyphen Reset Symbol Instance Symbolinstanz zurücksetzen @msg type must be static or dynamic, not {v} type muss static oder dynamic sein, nicht {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` ist ein statisches Symbol: Stellen Sie zuerst den Symboltyp auf „Dynamisch“ ein +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Bei einem in einer Symbolinstanz ausgewählten Pfad, zusammengesetzten Pfad oder Text lässt sich nur die Vollfarbe von Fläche und Kontur ändern @msg path must be a list of child indices path muss eine Liste von Kindindizes sein @msg no object at path {shown} in the symbol's art kein Objekt am Pfad {shown} in der Grafik des Symbols @msg give fill, stroke, opacity and/or blend geben Sie fill, stroke, opacity und/oder blend an +@msg {key} cannot be set on a guide {key} kann nicht für eine Hilfslinie gesetzt werden +@msg {key} cannot be set on a blend {key} kann nicht für eine Angleichung gesetzt werden +@msg {key} cannot be set on an envelope {key} kann nicht für eine Verzerrungshülle gesetzt werden +@msg {key} cannot be set on a gradient mesh {key} kann nicht für ein Verlaufsgitter gesetzt werden +@msg {key} cannot be set on a Repeat {key} kann nicht für eine Wiederholung gesetzt werden +@msg {key} cannot be set on a placed document {key} kann nicht für ein platziertes Dokument gesetzt werden +@msg {key} cannot be set on an Intertwine group {key} kann nicht für eine Verflechten-Gruppe gesetzt werden +@msg stroke cannot be set on a symbol instance stroke kann nicht für eine Symbolinstanz gesetzt werden +@msg fill on a symbol instance must be a solid color or none fill einer Symbolinstanz muss eine Vollfarbe oder none sein +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} kann nicht für ein Bild ohne den Effekt „In Form umwandeln“ oder „Objekt in Pfade umwandeln“ gesetzt werden @msg pass newName, registration, exportType, nineSlice and/or type geben Sie newName, registration, exportType, nineSlice und/oder type an Text Flow Textfluss By Rows Nach Zeilen @@ -6002,6 +6139,7 @@ breakText Glyphs Glyphen Create Swatch Legend Farbfeldlegende erstellen Create Swatch Legend… Farbfeldlegende erstellen… HEX HEX + Help Bar Hilfeleiste # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Textwerkzeuge Text Tweaker… Text-Tweaker… @@ -6067,3 +6205,44 @@ 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 plug-in of kind `{_2}`, not an importer `{_1}` ist ein Plug-in der Art `{_2}`, kein Import-Plug-in +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` ist ein Plug-in der Art `{_2}`, kein Export-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 + +# Safe Mode + Safe Mode… Abgesicherter Modus… + Safe Mode Abgesicherter Modus + Help › Safe Mode… Hilfe › Abgesicherter Modus… + Enable on Re-launch Beim nächsten Start aktivieren + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Der letzte Start von VectorCraft wurde beendet, während das Plug-in {name} geladen wurde. Es ist möglicherweise nicht die Ursache. + Disable and Continue Deaktivieren und fortfahren + Start Normally Normal starten + Start in Safe Mode Im abgesicherten Modus starten + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft läuft im abgesicherten Modus: Plug-ins werden weder geladen noch ausgeführt, bis VectorCraft normal startet. + Disabled plug-ins Deaktivierte Plug-ins + No plug-ins are disabled. Keine Plug-ins sind deaktiviert. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Der abgesicherte Modus ist aktiv: Plug-ins werden weder geladen noch ausgeführt, bis VectorCraft normal startet +@msg give `id` or `file` (see plugin.safeMode) `id` oder `file` angeben (siehe plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) dieses Plug-in ist nicht deaktiviert (siehe plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch Die deaktivierten Plug-ins konnten nicht für den nächsten Start gespeichert werden +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Aussehen umwandeln: Ein Plug-in-Effekt in der Auswahl kann im abgesicherten Modus nicht umgewandelt werden + +# Align to Pixel Grid + Align to Pixel Grid Am Pixelraster ausrichten +@msg only paths and compound paths can be aligned to the pixel grid nur Pfade und zusammengesetzte Pfade können am Pixelraster ausgerichtet werden +@msg side is left or right, not `{s}` side ist left oder right, nicht `{s}` diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index be001e2fb..cbd409387 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -178,6 +178,10 @@ Anti-Alias Εξομάλυνση ακμών Anti-alias Rasters Εξομάλυνση ακμών εικόνων ράστερ Anti-aliased Artwork Γραφικά με εξομαλυμένες ακμές + Anti-aliasing Method Μέθοδος εξομάλυνσης ακμών +antiAlias Sharp Οξεία + Crisp Καθαρή + Strong Έντονη Anti-aliasing: Εξομάλυνση ακμών: Any Kind Οποιοσδήποτε τύπος Font Technology Τεχνολογία γραμματοσειράς @@ -342,6 +346,13 @@ font-technology Variable Μεταβλητές Apply Bullets Εφαρμογή κουκκίδων Numbered List Αριθμημένη λίστα Apply Numbers Εφαρμογή αρίθμησης + Bullets and Numbering… Κουκκίδες και αρίθμηση… + Numbers Αριθμοί + Level: Επίπεδο: + Bullet Character: Χαρακτήρας κουκκίδας: + Number Style: Στυλ αρίθμησης: + Left Indent: Αριστερή εσοχή: + First Line Indent: Εσοχή πρώτης γραμμής: Convert to Text Μετατροπή σε κείμενο Filled Dot Γεμάτη κουκκίδα Dash Παύλα @@ -515,6 +526,11 @@ font-technology Variable Μεταβλητές Color Table ({n} colors) Πίνακας χρωμάτων ({n} χρώματα) Color Theme Χρωματικό θέμα Color Themes Χρωματικά θέματα + Color Wheel Χρωματικός τροχός + Hue ring Δακτύλιος απόχρωσης + Saturation and brightness Κορεσμός και φωτεινότητα + Color field Πεδίο χρώματος + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Τύπος χρώματος: Color: Χρώμα: Colorize Χρωματισμός @@ -1030,6 +1046,14 @@ font-technology Variable Μεταβλητές Export As SVG… Εξαγωγή ως SVG… Export Asset Εξαγωγή στοιχείου Export Assets Εξαγωγή στοιχείων + Export 3D Object Εξαγωγή αντικειμένου 3D + 3D Export Format Μορφή εξαγωγής 3D + Export 3D Assets Εξαγωγή στοιχείων 3D +@msg {what} can't be exported in 3D: {e} το {what} δεν μπορεί να εξαχθεί σε 3D: {e} +@msg asset {id} isn't a 3D asset το στοιχείο {id} δεν είναι στοιχείο 3D +@msg asset {id} is a 3D asset: assets.export3D writes it το στοιχείο {id} είναι στοιχείο 3D: το γράφει η assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) δεν υπάρχουν ακόμη στοιχεία 3D: συγκεντρώστε πρώτα ένα αντικείμενο με εφέ 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) επιλέξτε ένα αντικείμενο με εφέ 3D (ή δώστε τα αναγνωριστικά του) Export As… Εξαγωγή ως… Export CSS Εξαγωγή CSS Export CSS… Εξαγωγή CSS… @@ -1061,6 +1085,17 @@ font-technology Variable Μεταβλητές Exporting {} Εξαγωγή {} Export… Εξαγωγή… Extrude & Bevel… Εξώθηση και λοξότμηση… + 3D (Classic) 3D (κλασικό) + Extrude & Bevel (Classic)… Εξώθηση και λοξότμηση (κλασικό)… + Surface Επιφάνεια + Wireframe Συρματόπλεγμα + No Shading Χωρίς σκίαση + Plastic Πλαστικό + Highlight Intensity Ένταση ανταύγειας + Highlight Size Μέγεθος ανταύγειας + Blend Steps Βήματα ανάμειξης + Shading Color Χρώμα σκίασης + Draw Hidden Faces Σχεδίαση κρυφών εδρών Eyedropper Σταγονόμετρο (Eyedropper Tool) Eyedropper Applies: Το σταγονόμετρο εφαρμόζει: Eyedropper Options Επιλογές σταγονόμετρου @@ -1103,6 +1138,7 @@ font-technology Variable Μεταβλητές Live curve fitting Προσαρμογή καμπύλης σε πραγματικό χρόνο Fill Color Χρώμα γεμίσματος Fill New Type Objects With Placeholder Text Συμπλήρωση νέων αντικειμένων κειμένου με κείμενο υπόδειξης + Start Lists From Typed Markers Έναρξη λιστών από πληκτρολογημένα σύμβολα Fill Rule Κανόνας γεμίσματος Fill with Placeholder Text Γέμισμα με κείμενο υπόδειξης Fill: Γέμισμα: @@ -1113,6 +1149,7 @@ font-technology Variable Μεταβλητές Find Font… Εύρεση γραμματοσειράς… Find Next Εύρεση επόμενου Find Objects Εύρεση αντικειμένων + Compare with Image Σύγκριση με εικόνα First Page Πρώτη σελίδα First-line Left Indent Αριστερή εσοχή πρώτης γραμμής Fisheye… Εφέ ματιού ψαριού… @@ -1622,6 +1659,7 @@ font-technology Variable Μεταβλητές Report copied Η αναφορά αντιγράφηκε Line {line} Γραμμή {line} Line {line}: {name}: {status} Γραμμή {line}: {name}: {status} + Read Action Set File Ανάγνωση αρχείου συνόλου ενεργειών Read Shortcut Set File Ανάγνωση αρχείου συνόλου συντομεύσεων Import Matches Αντιστοιχίσεις εισαγωγής Remember Import Matches Απομνημόνευση αντιστοιχίσεων εισαγωγής @@ -1933,6 +1971,11 @@ font-technology Variable Μεταβλητές Select All Επιλογή όλων Select: Επιλογή: Selection Επιλογή +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Ορθογώνια επιλογή + Touching Αντικείμενα που τέμνονται + Fully Enclosed Πλήρως εντός + Drag Direction Κατεύθυνση σύρσης Selection Tool Επιλογή (Selection Tool) Send Backward Μεταφορά ένα βήμα πίσω Send to Back Μεταφορά στο παρασκήνιο @@ -2154,7 +2197,7 @@ axis Both Και τα δύο Dock Control Bar Αγκύρωση γραμμής ελέγχου @msg no library `{id}` Δεν υπάρχει βιβλιοθήκη με αναγνωριστικό `{id}` @msg select some type first Επιλέξτε πρώτα κάποιο κείμενο -@msg kerning must be a number, "auto", or "optical" Η διαγραμμάτωση πρέπει να είναι αριθμός, «auto» ή «optical» +@msg kerning must be a number, "auto", or "optical" το kerning πρέπει να είναι αριθμός, «auto» ή «optical» @msg the selection has no gradient fill η επιλογή δεν έχει ντεγκραντέ γεμίσματος @msg the selection has no gradient stroke η επιλογή δεν έχει ντεγκραντέ περιγράμματος @msg the selection has no pattern fill η επιλογή δεν έχει μοτίβο γεμίσματος @@ -2257,6 +2300,7 @@ axis Both Και τα δύο @msg Targa files are RGB: colorModel rgb Τα αρχεία Targa είναι RGB: colorModel rgb @msg Text Wrap: select a wrap object Αναδίπλωση κειμένου: επιλέξτε αντικείμενο αναδίπλωσης @msg This shortcut file is in a form VectorCraft can't read. Αυτό το αρχείο συντομεύσεων έχει μορφή που δεν μπορεί να διαβάσει το VectorCraft. +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" Το `antiAlias` πρέπει να είναι "none", "sharp", "crisp" ή "strong" @msg `aspect` must be a number (%) Το `aspect` πρέπει να είναι αριθμός (%) @msg `at` must be [x, y] Το `at` πρέπει να είναι [x, y] @msg `color` must be a colour Το `color` πρέπει να είναι χρώμα @@ -2750,6 +2794,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 Σταθερού πλάτους @@ -3325,6 +3373,7 @@ digits Malayalam Μαλαγιάλαμ Reset Transformation Επαναφορά μετασχηματισμού Replace With Font Αντικατάσταση με γραμματοσειρά Replace With Font From Αντικατάσταση με γραμματοσειρά από + Find In Αναζήτηση σε System Σύστημα Replaces the saved preset of that name. Αντικαθιστά την αποθηκευμένη προεπιλογή με αυτό το όνομα. Require a password to open the document Απαίτηση κωδικού για το άνοιγμα εγγράφου @@ -3611,6 +3660,7 @@ digits Malayalam Μαλαγιάλαμ Simulate Colored Paper Προσομοίωση χρωματιστού χαρτιού Simulate Paper Προσομοίωση χαρτιού Double Column Διπλή στήλη + Keep Tools Beside the Canvas Εργαλεία δίπλα στον καμβά Single Left Quotation Mark Μονό αριστερό εισαγωγικό Single Quotes: Μονά εισαγωγικά: Single Right Quotation Mark Μονό δεξί εισαγωγικό @@ -3791,6 +3841,15 @@ digits Malayalam Μαλαγιάλαμ Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Ρυθμίσεις Tate-chu-yoko… Tate-chu-yoko Settings Ρυθμίσεις Tate-chu-yoko + Composite Fonts… Σύνθετες γραμματοσειρές… + Composite Fonts Σύνθετες γραμματοσειρές + Export Composite Fonts Εξαγωγή σύνθετων γραμματοσειρών + Import Composite Fonts Εισαγωγή σύνθετων γραμματοσειρών + Delete Font Διαγραφή γραμματοσειράς + Kanji Κάντζι + Kana Κάνα + Numerals Αριθμοί + Scale at Center Κλιμάκωση από το κέντρο Up/Down: Πάνω/Κάτω: Left/Right: Αριστερά/Δεξιά: Temperature/Tint… Θερμοκρασία/Απόχρωση… @@ -3833,6 +3892,7 @@ digits Malayalam Μαλαγιάλαμ Tile Type: Τύπος πλακιδίου: tile {number} πλακίδιο {number} Tiles are numbered across, then down; leave the range empty for every tile. Τα πλακίδια αριθμούνται πρώτα οριζόντια και έπειτα κατακόρυφα· αφήστε κενό το εύρος για όλα τα πλακίδια. + Copies per Sheet (as many as fit, in a grid) Αντίγραφα ανά φύλλο (όσα χωρούν, σε πλέγμα) Tip extends past the end point Η αιχμή προεξέχει από το τελικό σημείο Tip on the end point Η αιχμή βρίσκεται στο τελικό σημείο Title Case Κεφαλαίο σε κάθε λέξη @@ -4035,6 +4095,7 @@ digits Malayalam Μαλαγιάλαμ Zig Zag… Ζιγκ-ζαγκ… Zoom to Selection Ζουμ στην επιλογή Zoom with Mouse Wheel Ζουμ με τον τροχό του ποντικιού + Scroll Sideways with Cmd/Ctrl + Wheel Οριζόντια κύλιση με Cmd/Ctrl + τροχό του ποντικιού Scrub Numeric Fields by Dragging Ρύθμιση αριθμητικών πεδίων με σύρσιμο @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} αρχείο στον φάκελο {folder} {note}. Να εμφανιστεί ο φάκελος συσκευασίας;|{n} αρχεία στον φάκελο {folder} {note}. Να εμφανιστεί ο φάκελος συσκευασίας; @plural {n} linked file not found|{n} linked files not found Δεν βρέθηκε {n} συνδεδεμένο αρχείο|Δεν βρέθηκαν {n} συνδεδεμένα αρχεία @@ -4084,7 +4145,7 @@ digits Malayalam Μαλαγιάλαμ Drag to float as a toolbar Σύρετε για αποδέσμευση ως γραμμή εργαλείων Put back in the toolbar Επαναφορά στη γραμμή εργαλείων Drag to float the toolbar Σύρετε για αποδέσμευση της γραμμής εργαλείων - Drag to the window's left edge to dock Σύρετε στο αριστερό άκρο του παραθύρου για σύνδεση + Drag to the window's left or right edge to dock Σύρετε στο αριστερό ή στο δεξί άκρο του παραθύρου για σύνδεση Float Tool Group Αποδέσμευση ομάδας εργαλείων @msg a PostScript error ({name} in `{op}`) σφάλμα PostScript ({name} στην εντολή `{op}`) @msg a PostScript error ({name} in `{op}`), in {procs} σφάλμα PostScript ({name} στην εντολή `{op}`), στις διαδικασίες {procs} @@ -4143,7 +4204,7 @@ digits Malayalam Μαλαγιάλαμ @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art ή type @msg artboard {0} does not exist ο πίνακας σχεδίασης {0} δεν υπάρχει @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard σε αυτήν την κλίμακα το σχέδιο είναι {w} × {h} pt και υπερβαίνει τον καμβά: επιλέξτε μικρότερη κλίμακα ή προσαρμόστε το στον πίνακα σχεδίασης -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` ο άξονας πρέπει να είναι "vertical", "horizontal" ή γωνία σε μοίρες, όχι `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` το axis πρέπει να είναι "vertical", "horizontal" ή γωνία σε μοίρες, όχι `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" φόντο {other}: transparent, white, black ή χρώμα όπως "#ff8800" @msg bad color {v} μη έγκυρο χρώμα {v} @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 Το videoRulerPixelAspectRatio πρέπει να είναι αριθμός από 0,1 έως 10 @@ -4152,9 +4213,9 @@ digits Malayalam Μαλαγιάλαμ @msg bad shortcut μη έγκυρη συντόμευση @msg bad stream length μη έγκυρο μήκος ροής @msg bad trailer μη έγκυρο τελικό τμήμα -@msg balance must be between 0 and 100 η ισορροπία πρέπει να είναι μεταξύ 0 και 100 +@msg balance must be between 0 and 100 το balance πρέπει να είναι μεταξύ 0 και 100 @msg batch step {_1} (`{id}`) failed: {e} απέτυχε το βήμα {_1} της μαζικής επεξεργασίας (`{id}`): {e} -@msg brightness must be dark|mediumDark|mediumLight|light η φωτεινότητα πρέπει να είναι dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light το brightness πρέπει να είναι dark|mediumDark|mediumLight|light @msg can't encode the document: {0} δεν είναι δυνατή η κωδικοποίηση εγγράφου: {0} @msg can't parse shortcut `{c}` δεν είναι δυνατή η ανάλυση της συντόμευσης `{c}` @msg can't parse shortcut `{s}` for `{k}` δεν είναι δυνατή η ανάλυση της συντόμευσης `{s}` για `{k}` @@ -4179,7 +4240,7 @@ digits Malayalam Μαλαγιάλαμ @msg compound paths can only contain paths οι σύνθετες διαδρομές μπορούν να περιέχουν μόνο διαδρομές @msg compressed (RLE) bitmaps can't be read yet τα συμπιεσμένα (RLE) bitmap δεν μπορούν ακόμη να διαβαστούν @msg damaged κατεστραμμένο -@msg decimals must be {_1}–{_2} (got {_3}) τα δεκαδικά πρέπει να είναι {_1}–{_2} (δόθηκε {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) το decimals πρέπει να είναι {_1}–{_2} (δόθηκε {_3}) @msg divide into 1 to {MAX_PIECES} slices διαιρέστε σε 1 έως {MAX_PIECES} τομές @msg effect {k} is `{_1}`, not `{id}` το εφέ {k} είναι `{_1}`, όχι `{id}` @msg empty brush stroke κενό περίγραμμα πινέλου @@ -4197,9 +4258,9 @@ digits Malayalam Μαλαγιάλαμ @msg an image has no pixels and was left out παραλείφθηκε εικόνα χωρίς εικονοστοιχεία @msg image data `{_1}` is missing λείπουν τα δεδομένα εικόνας `{_1}` @msg image would be too large; lower the resolution η εικόνα θα ήταν υπερβολικά μεγάλη· μειώστε την ανάλυση -@msg index must be a number or first, previous, next or last ο δείκτης πρέπει να είναι αριθμός ή first, previous, next ή last -@msg index must be a number or first, previous, next or last, not `{other}` ο δείκτης πρέπει να είναι αριθμός ή first, previous, next ή last, όχι `{other}` -@msg index must be ≥ 0 ο δείκτης δεν μπορεί να είναι αρνητικός +@msg index must be a number or first, previous, next or last το index πρέπει να είναι αριθμός ή first, previous, next ή last +@msg index must be a number or first, previous, next or last, not `{other}` το index πρέπει να είναι αριθμός ή first, previous, next ή last, όχι `{other}` +@msg index must be ≥ 0 το index πρέπει να είναι ≥ 0 @msg installing from a path is not available on the web; pass the module as `dataBase64` η εγκατάσταση από διαδρομή δεν υποστηρίζεται στο web· περάστε το πρόσθετο ως `dataBase64` @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) εσωτερικό σφάλμα στην εντολή `{cmd}`: {msg} (το έγγραφο έμεινε αμετάβλητο· αναφέρετε το σφάλμα) @msg internal error: {msg} (please report this bug) εσωτερικό σφάλμα: {msg} (αναφέρετε το σφάλμα) @@ -4212,7 +4273,7 @@ digits Malayalam Μαλαγιάλαμ @msg its Windows metafile preview was opened instead, which may simplify the art αντί γι' αυτό ανοίχτηκε η προεπισκόπηση μετααρχείου Windows, η οποία μπορεί να απλοποιεί το σχέδιο @msg its preview was placed instead, at screen resolution τοποθετήθηκε η προεπισκόπησή του σε ανάλυση οθόνης @msg join needs open paths η ένωση απαιτεί ανοιχτές διαδρομές -@msg lang must be one of {_1} η γλώσσα πρέπει να είναι μία από τις {_1} +@msg lang must be one of {_1} το lang πρέπει να είναι ένα από τα {_1} @msg layers can't be wrap objects τα επίπεδα δεν μπορούν να είναι αντικείμενα αναδίπλωσης @msg line {line}: value for `{var}` must be true or false γραμμή {line}: η τιμή για το `{var}` πρέπει να είναι true ή false @msg line {line}: value for `{var}` is longer than {MAX_GRAPH_CSV} bytes γραμμή {line}: η τιμή για το `{var}` είναι μεγαλύτερη από {MAX_GRAPH_CSV} byte @@ -4304,6 +4365,8 @@ digits Malayalam Μαλαγιάλαμ @msg no such action set δεν υπάρχει τέτοιο σύνολο ενεργειών @msg the action sets changed; nothing was deleted τα σύνολα ενεργειών άλλαξαν· δεν διαγράφηκε τίποτα @msg no action set to save δεν υπάρχει σύνολο ενεργειών για αποθήκευση +@msg the action set file can't be read in full το αρχείο συνόλου ενεργειών δεν μπορεί να διαβαστεί ολόκληρο +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Φορτώθηκε το «{name}»: παραλείφθηκε {n} βήμα|Φορτώθηκε το «{name}»: παραλείφθηκαν {n} βήματα @msg not an action set file δεν είναι αρχείο συνόλου ενεργειών @msg the action set file is larger than 8 MiB το αρχείο συνόλου ενεργειών είναι μεγαλύτερο από 8 MiB @msg the action set file holds more than 1,000 actions το αρχείο συνόλου ενεργειών περιέχει περισσότερες από 1.000 ενέργειες @@ -4378,9 +4441,9 @@ digits Malayalam Μαλαγιάλαμ @msg replace: the object to replace was changed or deleted αντικατάσταση: το αντικείμενο προς αντικατάσταση άλλαξε ή διαγράφηκε @msg pattern missing λείπει το μοτίβο @msg patterns nested too deeply are filled with mid-grey τα πολύ βαθιά ένθετα μοτίβα γεμίζονται με μέτριο γκρι -@msg percent must be above 0 and at most 6400, not {p} το ποσοστό πρέπει να είναι μεγαλύτερο από 0 και έως 6400, όχι {p} +@msg percent must be above 0 and at most 6400, not {p} το percent πρέπει να είναι μεγαλύτερο από 0 και έως 6400, όχι {p} @msg pick a colour, gradient or pattern to save as a swatch επιλέξτε χρώμα, ντεγκραντέ ή μοτίβο για αποθήκευση ως δείγμα -@msg plane must be left, right or ground το επίπεδο πρέπει να είναι left, right ή ground +@msg plane must be left, right or ground το plane πρέπει να είναι left, right ή ground @msg plug-in ABI: {0} ABI προσθέτου: {0} @msg plug-in exceeded its {0} το πρόσθετο υπερέβη το όριο {0} @msg plug-in failed: {0} το πρόσθετο απέτυχε: {0} @@ -4399,8 +4462,8 @@ digits Malayalam Μαλαγιάλαμ @msg result would exceed the canvas (coordinates out of range) το αποτέλεσμα θα υπερέβαινε τον καμβά (συντεταγμένες εκτός εύρους) @msg objects can't be nested more than 32 deep τα αντικείμενα δεν μπορούν να έχουν ένθεση μεγαλύτερη των 32 επιπέδων @msg the SVG's elements are nested more than {0} deep τα στοιχεία SVG έχουν ένθεση μεγαλύτερη των {0} επιπέδων -@msg scale must be a number from {_1} to {_2}, not {_3} η κλίμακα πρέπει να είναι αριθμός από {_1} έως {_2}, όχι {_3} -@msg scale must be a number from {_1} to {_2}, not {s} η κλίμακα πρέπει να είναι αριθμός από {_1} έως {_2}, όχι {s} +@msg scale must be a number from {_1} to {_2}, not {_3} το scale πρέπει να είναι αριθμός από {_1} έως {_2}, όχι {_3} +@msg scale must be a number from {_1} to {_2}, not {s} το scale πρέπει να είναι αριθμός από {_1} έως {_2}, όχι {s} @msg select (or target) objects whose appearance isn't basic επιλέξτε (ή στοχεύστε) αντικείμενα των οποίων η εμφάνιση δεν είναι βασική @msg select a Live Paint group επιλέξτε ομάδα δυναμικής βαφής @msg select a blend επιλέξτε ανάμειξη @@ -4422,6 +4485,13 @@ digits Malayalam Μαλαγιάλαμ @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 επιλέξτε αντικείμενα, όχι επίπεδα @@ -4659,11 +4729,15 @@ digits Malayalam Μαλαγιάλαμ @msg its objects are nested too deeply τα αντικείμενά του έχουν υπερβολικά βαθιά ένθεση @msg its section ending with `%{end}` doesn't end η ενότητά του που τελειώνει σε `%{end}` δεν τερματίζεται @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) τα πινέλα που το VectorCraft δεν μπορεί ακόμη να διαβάσει παραλείφθηκαν από τον πίνακα Πινέλα ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet τα αντικείμενα με πολλαπλά γεμίσματα, περιγράμματα, εφέ ή πινέλα εισήχθησαν ως απεικονίσεις ({_1}): η εμφάνισή τους δεν είναι ακόμη επεξεργάσιμη +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet τα αντικείμενα με πολλαπλά γεμίσματα, περιγράμματα, περιγράμματα μεταβλητού πλάτους, εφέ ή πινέλα εισήχθησαν ως απεικονίσεις ({_1}): η εμφάνισή τους δεν είναι ακόμη επεξεργάσιμη +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding το όνομα `{name}` δεν είναι UTF-8 και διαβάστηκε ως {label}: αν φαίνεται λάθος, ανοίξτε το αρχείο με άλλο legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding το όνομα `{name}` δεν είναι UTF-8 και τμήματά του δεν είναι έγκυρα ούτε σε {label}: ανοίξτε το αρχείο με άλλο legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) περισσότερα ονόματα που δεν είναι UTF-8 διαβάστηκαν ως {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it το ντεγκραντέ «{name}» δεν ορίζεται στο αρχείο: χρησιμοποιείται στη θέση του ένα συμπαγές χρώμα @msg an image VectorCraft can't read was left out παραλείφθηκε εικόνα που δεν μπορεί να διαβάσει το VectorCraft @msg graphs come in as plain art, without their data or settings τα γραφήματα εισάγονται ως απλή γραφική, χωρίς τα δεδομένα και τις ρυθμίσεις τους @msg an image whose data is cut short was left out παραλείφθηκε εικόνα με περικομμένα δεδομένα +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers μια ενσωματωμένη εικόνα {_1} × {_2} pixel μειώθηκε σε {_3} × {_4} pixel για να διατηρηθούν τα επίπεδα @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel οι συνδεδεμένες εικόνες εμφανίζουν την προεπισκόπηση χαμηλής ανάλυσης που έχει αποθηκευτεί, μέχρι να βρεθούν τα αρχεία τους: επανασυνδέστε τις στον πίνακα Σύνδεσμοι @msg linked type areas past VectorCraft's limits were left out οι συνδεδεμένες περιοχές κειμένου πέρα από τα όρια του VectorCraft παραλείφθηκαν @msg type that changes writing direction inside a text object opens in the direction of its first character το κείμενο που αλλάζει κατεύθυνση γραφής μέσα σε ένα αντικείμενο κειμένου ανοίγει με την κατεύθυνση του πρώτου χαρακτήρα του @@ -4737,6 +4811,7 @@ digits Malayalam Μαλαγιάλαμ @msg object ids are non-negative integers, not {v} τα αναγνωριστικά αντικειμένων πρέπει να είναι μη αρνητικοί ακέραιοι, όχι {v} @msg {key} must be an array of non-negative integer object ids {key}: πρέπει να είναι πίνακας αναγνωριστικών αντικειμένων που είναι μη αρνητικοί ακέραιοι @msg invalid object id {value}: expected a non-negative integer μη έγκυρο αναγνωριστικό αντικειμένου {value}: αναμένεται μη αρνητικός ακέραιος +@msg `{key}` entry {index} is {value}, not a non-negative integer object id το στοιχείο {index} του `{key}` είναι {value}, όχι αναγνωριστικό αντικειμένου (μη αρνητικός ακέραιος) @msg no such object id {value} δεν υπάρχει αντικείμενο με αναγνωριστικό {value} @msg id {value} is a layer, not an object το αναγνωριστικό {value} είναι επίπεδο, όχι αντικείμενο @msg id {value} is a layer, not an object; move layers with layer.move το αναγνωριστικό {value} είναι επίπεδο, όχι αντικείμενο· μετακινήστε τα επίπεδα με το layer.move @@ -4763,17 +4838,27 @@ digits Malayalam Μαλαγιάλαμ @msg the Pixar file's image data is damaged τα δεδομένα εικόνας του αρχείου Pixar είναι κατεστραμμένα @msg the JPEG 2000 file can't be read δεν είναι δυνατή η ανάγνωση του αρχείου JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content το αρχείο JPEG 2000 έχει μη υποστηριζόμενο χρωματικό χώρο ή περιεχόμενο JPX +@msg the HEIC or HEIF file can't be read δεν είναι δυνατή η ανάγνωση του αρχείου HEIC ή HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read το αρχείο HEIC ή HEIF περιέχει είδος εικόνας που δεν είναι δυνατό να διαβαστεί +@msg HEIC and HEIF images can't be opened in this build οι εικόνες HEIC και HEIF δεν μπορούν να ανοίξουν σε αυτή την έκδοση @msg the PSD image is too large to read ({_1} × {_2} pixels) η εικόνα PSD είναι πολύ μεγάλη για ανάγνωση ({_1} × {_2} εικονοστοιχεία) @msg zipped 32-bit PSD image data with prediction can't be read τα συμπιεσμένα δεδομένα εικόνας PSD 32 bit με πρόβλεψη δεν διαβάζονται @msg {depth}-bit PSD files in this colour mode can't be read τα αρχεία PSD {depth} bit δεν διαβάζονται σε αυτήν τη λειτουργία χρώματος @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 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 Δημιουργία νέων πεδίων κατά @msg Imported library {name} Εισήχθη η βιβλιοθήκη {name} RGB: RGB: CMYK: CMYK: System Title Bar Γραμμή τίτλου συστήματος + VectorCraft Title Bar on Every Desktop Γραμμή τίτλου του VectorCraft σε κάθε επιφάνεια εργασίας + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Όσο είναι ορισμένη, η μεταβλητή περιβάλλοντος VECTORCRAFT_TITLEBAR επιλέγει τη γραμμή τίτλου. + System Title Bar takes precedence over this option while it is on. Όσο είναι ενεργή, η Γραμμή τίτλου συστήματος υπερισχύει αυτής της επιλογής. Value Axis Άξονας τιμών On Left Side Στην αριστερή πλευρά On Right Side Στη δεξιά πλευρά @@ -4786,12 +4871,12 @@ digits Malayalam Μαλαγιάλαμ Edit Data Set Επεξεργασία συνόλου δεδομένων Define a variable first Ορίστε πρώτα μία μεταβλητή @msg Invalid panel layout; restored the default dock Μη έγκυρη διάταξη πινάκων· επανήλθε η προεπιλεγμένη περιοχή σύνδεσης -@msg action is required απαιτείται ενέργεια +@msg action is required απαιτείται το action @msg anchor must name a registered panel το anchor πρέπει να ορίζει καταχωρισμένο πίνακα @msg before must name a panel το before πρέπει να ορίζει πίνακα @msg docking action contains an unknown panel or an excessive split path η ενέργεια σύνδεσης περιέχει άγνωστο πίνακα ή υπερβολικά βαθιά διαδρομή διαίρεσης @msg group must be true or false το group πρέπει να είναι true ή false -@msg panel is not in a dock group Το πάνελ δεν βρίσκεται σε ομάδα αγκύρωσης +@msg panel is not in a dock group το panel δεν βρίσκεται σε ομάδα αγκύρωσης @msg only a dock group above or below others collapses, not one beside another συμπτύσσεται μόνο ομάδα αγκύρωσης πάνω ή κάτω από άλλες, όχι δίπλα σε άλλη @msg icon must be true or false το icon πρέπει να είναι true ή false @msg icon only docks a panel in the icon column, with no anchor το icon αγκυρώνει πάνελ μόνο στη στήλη εικονιδίων, χωρίς anchor @@ -5248,7 +5333,7 @@ effect Vertical Κατακόρυφο effect Points Σημεία Single Corner Radius Ακτίνα μίας γωνίας CMYK CMYK -@msg control must be fillStroke, coloring, drawMode or screenMode Το στοιχείο ελέγχου πρέπει να είναι fillStroke, coloring, drawMode ή screenMode +@msg control must be fillStroke, coloring, drawMode or screenMode το control πρέπει να είναι fillStroke, coloring, drawMode ή screenMode Draw Mode Controls Στοιχεία ελέγχου λειτουργίας σχεδίασης Hold Κρατήστε πατημένο Latin Λατινικά @@ -5256,6 +5341,11 @@ effect Points Σημεία Load EPS into Clipboard Φόρτωση EPS στο πρόχειρο @msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode Άγνωστο στοιχείο ελέγχου `{name}`: fillStroke, coloring, drawMode ή screenMode @msg an image of {w} × {h} pixels is too large to draw Η εικόνα {w} × {h} εικονοστοιχείων είναι υπερβολικά μεγάλη για σχεδίαση +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: δεν είναι εικόνα PNG, JPEG, WebP, TIFF ή BMP +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ η εικόνα είναι {iw} × {ih} εικονοστοιχεία και ο πίνακας σχεδίασης σε αυτό το πλάτος είναι {rw} × {rh} εικονοστοιχεία: οι αναλογίες τους διαφέρουν +@msg `threshold` must be a number from 0 to 1 Το `threshold` πρέπει να είναι αριθμός από 0 έως 1 +@msg background {v}: a color such as "#808080" φόντο {v}: χρώμα όπως "#808080" +@msg comparing with an image file isn't available in the browser η σύγκριση με αρχείο εικόνας δεν είναι διαθέσιμη στο πρόγραμμα περιήγησης @msg an image of {w} × {h} pixels is shown at reduced size Η εικόνα {w} × {h} εικονοστοιχείων εμφανίζεται σε μειωμένο μέγεθος Open Recent File 1 Άνοιγμα πρόσφατου αρχείου 1 Open Recent File 2 Άνοιγμα πρόσφατου αρχείου 2 @@ -5656,11 +5746,19 @@ effect Points Σημεία @msg too many library panels were opened in this session άνοιξαν πάρα πολλοί πίνακες βιβλιοθηκών σε αυτή τη συνεδρία @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Η εικόνα είναι {_1} × {_2} εικονοστοιχεία, πάνω από {LARGE_TRACE_SIDE} σε μία πλευρά: ανιχνεύεται, αλλά πιο αργά. Μια προρύθμιση χαμηλότερης πιστότητας ή ένα μικρότερο αντίγραφο της εικόνας ανιχνεύεται γρηγορότερα. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Η εικόνα ανιχνεύτηκε σε {_1} × {_2} εικονοστοιχεία, σμικρυμένη από {_3} × {_4} (το πολύ {_5} megapixel). +@msg byColor must be true or false το byColor πρέπει να είναι true ή false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping το byColor δεν μπορεί να χωρίσει τις ομάδες της Αυτόματης ομαδοποίησης: ανιχνεύστε χωρίς Αυτόματη ομαδοποίηση +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor το byColor δεν μπορεί να αναομαδοποιήσει μια ανίχνευση με ενεργό εφέ Pathfinder: επεκτείνετε χωρίς byColor Snapping Προσκόλληση Snap to Tangent Προσκόλληση σε εφαπτομένη Snap to Perpendicular Προσκόλληση σε κάθετο @msg unknown snap option `{key}` (use {options}) άγνωστη επιλογή προσκόλλησης `{key}` (χρησιμοποιήστε {options}) Gap Length: Μήκος κενού: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Η λειτουργία Επίπεδο λογότυπο δεν χρησιμοποιεί το keepColors: ανιχνεύει τα χρώματα που ορίζονται στο logoColors ή τα χρώματα που βρίσκει. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Δεν ανιχνεύτηκε τίποτα: κανένα από τα χρώματα που βρήκε η ανίχνευση δεν αντιστοιχεί στο keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Δεν ανιχνεύτηκε τίποτα: η Παράβλεψη χρώματος παραλείπει κάθε περιοχή των χρωμάτων που ορίζει το keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Δεν ανιχνεύτηκε τίποτα: τα χρώματα που ταιριάζουν με το keepColors δεν δίνουν καμία διαδρομή. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: κανένα από τα χρώματα που βρήκε η ανίχνευση δεν αντιστοιχεί στο {c}, οπότε τίποτα από αυτό δεν ανιχνεύεται. @msg `colorGroup` needs `library: "document"` το `colorGroup` απαιτεί `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` το `colorGroup` απαιτεί `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` το `colorGroup` απαιτεί `mode: "color"` @@ -5772,11 +5870,14 @@ axis Y Axis Άξονας Y @msg Running “{name}”… Εκτέλεση του «{name}»… @msg a live effect runs while the document is drawn, so it can't be `heavy` ένα δυναμικό εφέ εκτελείται κατά τη σχεδίαση του εγγράφου, οπότε δεν μπορεί να είναι `heavy` @msg an exporter always gets an export's budget: leave `budget` out ένα πρόσθετο εξαγωγής παίρνει πάντα τον προϋπολογισμό μιας εξαγωγής: παραλείψτε το `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees το «{_1}» ζητά μεγάλο υπολογιστικό προϋπολογισμό (κάθε εκτέλεση έως {minutes} λεπτά και {times} φορές τις συνήθεις εντολές): εγκαταστήστε το με `allowHeavy: true` μόλις συμφωνήσει ο χρήστης +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants το «{_1}» ζητά μεγάλο υπολογιστικό προϋπολογισμό (κάθε εκτέλεση έως {minutes} λεπτά και {times} φορές τις συνήθεις εντολές): πρέπει να το εγκρίνει ένα άτομο στην εφαρμογή επιφάνειας εργασίας ή περάστε `allowHeavy: true` σε μια συνεδρία που ξεκίνησε με --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Να εγκατασταθεί το «{name}», που ζητά αυτά τα δικαιώματα; ID: {id} Αναγνωριστικό: {id} Version: {version} Έκδοση: {version} + Install “{name}” in place of the plug-in with the same ID? Να εγκατασταθεί το «{name}» στη θέση του πρόσθετου με το ίδιο αναγνωριστικό; + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Αυτό το αρχείο δεν είναι το πρόσθετο που ήταν εγκατεστημένο πριν με αυτό το αναγνωριστικό. Παίρνει τις ρυθμίσεις και την κρυφή μνήμη που διατηρούνται για αυτό το αναγνωριστικό. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees το «{name}» είναι άλλη λειτουργική μονάδα από το πρόσθετο με αναγνωριστικό {_1} και θα έπαιρνε τις ρυθμίσεις και την κρυφή μνήμη που διατηρούνται για αυτό: εγκαταστήστε το με `replace: true` μόλις συμφωνήσει ο χρήστης File SHA-256: {hash} SHA-256 αρχείου: {hash} Read the objects it is given Ανάγνωση των αντικειμένων που του δίνονται Change, add and delete objects in the document Αλλαγή, προσθήκη και διαγραφή αντικειμένων στο έγγραφο @@ -5784,21 +5885,43 @@ 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 Εγγραφή του εγγράφου ως αρχείου σε δική του μορφή Show its progress, see Cancel and write log lines Εμφάνιση της προόδου, αναγνώριση της ακύρωσης και εγγραφή γραμμών καταγραφής -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} το δικαίωμα `progress` χρειάζεται το επίπεδο API προσθέτων {PROGRESS_API}: δώστε `api` {PROGRESS_API} + Keep settings of its own Διατήρηση δικών του ρυθμίσεων + Keep a cache of its own, which you can clear Διατήρηση δικής του κρυφής μνήμης, την οποία μπορείτε να εκκαθαρίσετε + Clear Plug-in Cache Εκκαθάριση κρυφής μνήμης προσθέτου +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} το δικαίωμα `{permission}` χρειάζεται το επίπεδο API προσθέτων {PROGRESS_API}: δώστε `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} η λειτουργική μονάδα εισάγει το `env.{name}` με άλλον τύπο από αυτόν του κεντρικού υπολογιστή: ο τύπος του είναι {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` η λειτουργική μονάδα εισάγει το `env.{name}`, το οποίο ένα πρόσθετο μπορεί να χρησιμοποιεί μόνο στο επίπεδο API προσθέτων {PROGRESS_API} (`api` {PROGRESS_API}) και με το δικαίωμα `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network η λειτουργική μονάδα εισάγει το `{from}.{name}`, το οποίο δεν προσφέρει ο κεντρικός υπολογιστής: ένα πρόσθετο μπορεί να εισάγει μόνο τα `env.vc_progress`, `env.vc_cancelled` και `env.vc_log` (επίπεδο API προσθέτων 2) και κανένα WASI, αρχεία ή δίκτυο +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` η λειτουργική μονάδα εισάγει το `env.{name}`, το οποίο ένα πρόσθετο μπορεί να χρησιμοποιεί μόνο στο επίπεδο API προσθέτων {PROGRESS_API} (`api` {PROGRESS_API}) και με το δικαίωμα `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network η λειτουργική μονάδα εισάγει το `{from}.{name}`, το οποίο δεν προσφέρει ο κεντρικός υπολογιστής: ένα πρόσθετο μπορεί να εισάγει μόνο τις συναρτήσεις `env.vc_*` που απαριθμεί το docs/plugins.md (επίπεδο API προσθέτων 2) και κανένα WASI, αρχεία ή δίκτυο @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} το `api` είναι {_1}· αυτός ο κεντρικός υπολογιστής υποστηρίζει τα επίπεδα API προσθέτων 1 έως {API_LEVEL} +@msg `paintOutput` may only be true Το `paintOutput` μπορεί να είναι μόνο true +@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}` @msg at most {MAX_PERMISSIONS} `permissions` το πολύ {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` το δικαίωμα `{shown}` δεν ισχύει για πρόσθετο τύπου `{_1}` @msg permission `{shown}` is listed twice το δικαίωμα `{shown}` αναφέρεται δύο φορές +@msg only a filter declares `inputs` μόνο ένα φίλτρο δηλώνει `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance το `inputs` αναφέρει άγνωστο είδος αντικειμένου `{shown}`: χρησιμοποιήστε type, image ή symbolInstance +@msg `inputs` lists `{shown}` twice το `inputs` αναφέρει το `{shown}` δύο φορές +@msg `inputs` needs `api` 2 or later το `inputs` απαιτεί `api` 2 ή νεότερο +@msg `inputs` needs the permission `doc.read` το `inputs` απαιτεί το δικαίωμα `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) προϋπολογισμός μνήμης (το αντικείμενο {_1} χρειάζεται {size} byte εισόδου, απομένουν {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) προϋπολογισμός αντικειμένων (περισσότερα από {MAX_OBJECTS} αντικείμενα μόνο για ανάγνωση) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it το δικαίωμα `{shown}` είναι δεσμευμένο για μεταγενέστερο API προσθέτων· αυτός ο κεντρικός υπολογιστής δεν το παραχωρεί @msg unknown permission `{shown}` άγνωστο δικαίωμα `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees το «{name}» ζητά τα δικαιώματα {asked}: εγκαταστήστε το με ένα `grant` που τα απαριθμεί μόλις συμφωνήσει ο χρήστης +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants το «{name}» ζητά τα δικαιώματα {asked}: πρέπει να τα εγκρίνει ένα άτομο στην εφαρμογή επιφάνειας εργασίας ή περάστε `grant` σε μια συνεδρία που ξεκίνησε με --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app τα `grant` και `allowHeavy` ισχύουν μόνο σε συνεδρία που ξεκίνησε με --allow-plugin-grants: εγκρίνετε το πρόσθετο στην εφαρμογή επιφάνειας εργασίας +@msg Only the person at the keyboard can approve installing a plug-in Μόνο το άτομο στο πληκτρολόγιο μπορεί να εγκρίνει την εγκατάσταση ενός πρόσθετου @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed στο «{name}» δεν παραχωρήθηκε το `{p}`, που χρειάζεται η έξοδός του: δεν άλλαξε τίποτα +@msg a heavy filter runs in the background, so it can't declare `commands` ένα βαρύ φίλτρο εκτελείται στο παρασκήνιο, οπότε δεν μπορεί να δηλώνει `commands` +@msg at most {MAX_COMMANDS} `commands` το πολύ {MAX_COMMANDS} `commands` +@msg command {name} is declared twice η εντολή {name} δηλώνεται δύο φορές +@msg command {name}: `label` must be 1-64 printable characters εντολή {name}: το `label` πρέπει να έχει 1-64 εκτυπώσιμους χαρακτήρες +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter εντολή {name}: χρησιμοποιήστε αναγνωριστικό 1-{MAX_COMMAND_ID} χαρακτήρων από a-z 0-9 _ που αρχίζει με γράμμα +@msg command {name}: {e} εντολή {name}: {e} +@msg only a filter or a live effect declares `commands` μόνο ένα φίλτρο ή ένα δυναμικό εφέ δηλώνει `commands` +@msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed η λειτουργική μονάδα δεν έχει το SHA-256 που δίνει το `hash`: δεν εγκαταστάθηκε τίποτα # Actions panel: action sets (#3712). New Set… Νέο σύνολο… @@ -6216,6 +6339,8 @@ breakText Glyphs Γλύφοι File + Data Set Name Αρχείο + όνομα συνόλου δεδομένων Data Set Name Όνομα συνόλου δεδομένων @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` Το `datasetFileNaming` πρέπει να είναι `fileNumber`, `fileDataSetName` ή `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Κάθε σύνολο δεδομένων αποθηκεύεται πάνω στο ίδιο αρχείο, το οποίο κρατά μόνο το τελευταίο σύνολο δεδομένων. +@msg save the document with Save As before Save and Close αποθηκεύστε το έγγραφο με «Αποθήκευση ως» πριν από «Αποθήκευση και κλείσιμο» @msg no free data set file name in destination folder Δεν υπάρχει διαθέσιμο όνομα αρχείου συνόλου δεδομένων στον φάκελο προορισμού # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6336,9 +6461,20 @@ breakText Glyphs Γλύφοι Reset Symbol Instance Επαναφορά στιγμιότυπου συμβόλου @msg type must be static or dynamic, not {v} το type πρέπει να είναι static ή dynamic, όχι {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first το `{name}` είναι στατικό σύμβολο: ορίστε πρώτα τον τύπο συμβόλου σε Δυναμικό +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Σε διαδρομή, σύνθετη διαδρομή ή κείμενο που έχει επιλεγεί σε στιγμιότυπο συμβόλου αλλάζει μόνο το συμπαγές χρώμα του γεμίσματος και της πινελιάς @msg path must be a list of child indices το path πρέπει να είναι λίστα δεικτών θυγατρικών αντικειμένων @msg no object at path {shown} in the symbol's art δεν υπάρχει αντικείμενο στη διαδρομή {shown} στο γραφικό του συμβόλου @msg give fill, stroke, opacity and/or blend δώστε fill, stroke, opacity ή/και blend +@msg {key} cannot be set on a guide το {key} δεν μπορεί να οριστεί σε οδηγό +@msg {key} cannot be set on a blend το {key} δεν μπορεί να οριστεί σε ανάμειξη +@msg {key} cannot be set on an envelope το {key} δεν μπορεί να οριστεί σε περίβλημα +@msg {key} cannot be set on a gradient mesh το {key} δεν μπορεί να οριστεί σε πλέγμα ντεγκραντέ +@msg {key} cannot be set on a Repeat το {key} δεν μπορεί να οριστεί σε επανάληψη +@msg {key} cannot be set on a placed document το {key} δεν μπορεί να οριστεί σε τοποθετημένο έγγραφο +@msg {key} cannot be set on an Intertwine group το {key} δεν μπορεί να οριστεί σε ομάδα διαπλοκής +@msg stroke cannot be set on a symbol instance το stroke δεν μπορεί να οριστεί σε στιγμιότυπο συμβόλου +@msg fill on a symbol instance must be a solid color or none το fill ενός στιγμιότυπου συμβόλου πρέπει να είναι συμπαγές χρώμα ή none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it το {key} δεν μπορεί να οριστεί σε εικόνα χωρίς το εφέ «Μετατροπή σε σχήμα» ή «Μετατροπή αντικειμένου σε περιγράμματα» @msg pass newName, registration, exportType, nineSlice and/or type δώστε newName, registration, exportType, nineSlice ή/και type Text Flow Ροή κειμένου By Rows Κατά γραμμές @@ -6368,6 +6504,7 @@ breakText Glyphs Γλύφοι Create Swatch Legend Δημιουργία υπομνήματος δειγμάτων χρωμάτων Create Swatch Legend… Δημιουργία υπομνήματος δειγμάτων χρωμάτων… HEX HEX + Help Bar Γραμμή βοήθειας # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Εργαλεία κειμένου Text Tweaker… Ρύθμιση κειμένου… @@ -6433,3 +6570,44 @@ 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 plug-in of kind `{_2}`, not an importer το `{_1}` είναι πρόσθετο τύπου `{_2}`, όχι πρόσθετο εισαγωγής +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter το `{_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` + +# Safe Mode + Safe Mode… Ασφαλής λειτουργία… + Safe Mode Ασφαλής λειτουργία + Help › Safe Mode… Βοήθεια › Ασφαλής λειτουργία… + Enable on Re-launch Ενεργοποίηση στην επόμενη εκκίνηση + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Η τελευταία εκκίνηση του VectorCraft σταμάτησε ενώ φορτωνόταν το πρόσθετο {name}. Ίσως δεν είναι αυτό η αιτία. + Disable and Continue Απενεργοποίηση και συνέχεια + Start Normally Κανονική εκκίνηση + Start in Safe Mode Εκκίνηση σε ασφαλή λειτουργία + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. Το VectorCraft είναι σε ασφαλή λειτουργία: κανένα πρόσθετο δεν φορτώνεται ούτε εκτελείται μέχρι να ξεκινήσει κανονικά. + Disabled plug-ins Απενεργοποιημένα πρόσθετα + No plug-ins are disabled. Δεν υπάρχουν απενεργοποιημένα πρόσθετα. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Η ασφαλής λειτουργία είναι ενεργή: τα πρόσθετα δεν φορτώνονται ούτε εκτελούνται μέχρι το VectorCraft να ξεκινήσει κανονικά +@msg give `id` or `file` (see plugin.safeMode) δώστε `id` ή `file` (δείτε plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) αυτό το πρόσθετο δεν είναι απενεργοποιημένο (δείτε plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch Δεν ήταν δυνατή η αποθήκευση των απενεργοποιημένων πρόσθετων για την επόμενη εκκίνηση +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Επέκταση εμφάνισης: ένα εφέ πρόσθετου στην επιλογή δεν μπορεί να επεκταθεί σε ασφαλή λειτουργία + +# Align to Pixel Grid + Align to Pixel Grid Στοίχιση στο πλέγμα εικονοστοιχείων +@msg only paths and compound paths can be aligned to the pixel grid μόνο διαδρομές και σύνθετες διαδρομές μπορούν να στοιχιστούν στο πλέγμα εικονοστοιχείων +@msg side is left or right, not `{s}` το side είναι left ή right, όχι `{s}` diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 930ecf090..1d923ccbb 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -198,6 +198,10 @@ Anti-Alias Suavizado Anti-alias Rasters Suavizar imágenes ráster Anti-aliased Artwork Ilustración suavizada + Anti-aliasing Method Método de suavizado +antiAlias Sharp Nítido + Crisp Definido + Strong Fuerte Anti-aliasing: Suavizado: Any Kind Cualquier tipo Font Technology Tecnología de fuente @@ -367,6 +371,13 @@ font-technology Variable Variables Apply Bullets Aplicar viñetas Numbered List Lista numerada Apply Numbers Aplicar numeración + Bullets and Numbering… Viñetas y numeración… + Numbers Números + Level: Nivel: + Bullet Character: Carácter de viñeta: + Number Style: Estilo de número: + Left Indent: Sangría izquierda: + First Line Indent: Sangría de primera línea: Convert to Text Convertir en texto Filled Dot Punto relleno Dash Guion @@ -555,6 +566,11 @@ font-technology Variable Variables Color Table ({n} colors) Tabla de colores ({n} colores) Color Theme Tema de color Color Themes Temas de color + Color Wheel Rueda de color + Hue ring Anillo de tono + Saturation and brightness Saturación y brillo + Color field Campo de color + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Tipo de color: Color: Color: Colorize Colorear @@ -1086,6 +1102,14 @@ font-technology Variable Variables Export As SVG… Exportar como SVG… Export Asset Exportar recurso Export Assets Exportar recursos + Export 3D Object Exportar objeto 3D + 3D Export Format Formato de exportación 3D + Export 3D Assets Exportar recursos 3D +@msg {what} can't be exported in 3D: {e} {what} no se puede exportar en 3D: {e} +@msg asset {id} isn't a 3D asset el recurso {id} no es un recurso 3D +@msg asset {id} is a 3D asset: assets.export3D writes it el recurso {id} es un recurso 3D: lo escribe assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) todavía no hay recursos 3D: reúna primero un objeto con un efecto 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) seleccione un objeto con un efecto 3D (o indique sus id) Export As… Exportar como… Export CSS Exportar CSS Export CSS… Exportar CSS… @@ -1117,6 +1141,17 @@ font-technology Variable Variables Exporting {} Exportando {} Export… Exportar… Extrude & Bevel… Extrusión y biselado… + 3D (Classic) 3D (clásico) + Extrude & Bevel (Classic)… Extrusión y biselado (clásico)… + Surface Superficie + Wireframe Malla + No Shading Sin sombreado + Plastic Plástico + Highlight Intensity Intensidad del brillo + Highlight Size Tamaño del brillo + Blend Steps Pasos de fusión + Shading Color Color de sombreado + Draw Hidden Faces Dibujar caras ocultas Eyedropper Cuentagotas Sample Color from Screen Tomar color de la pantalla This system has no screen color sampler Este sistema no tiene muestreador de color de la pantalla @@ -1173,6 +1208,7 @@ font-technology Variable Variables Stroke When Editable Trazo si es editable Fill Color Color de relleno Fill New Type Objects With Placeholder Text Rellenar nuevos objetos de texto con texto de muestra + Start Lists From Typed Markers Iniciar listas a partir de marcadores escritos Fill Rule Regla de relleno Fill with Placeholder Text Rellenar con texto de muestra Insert Inline Symbol Insertar símbolo en el texto @@ -1184,6 +1220,7 @@ font-technology Variable Variables Find Font… Buscar fuente… Find Next Buscar siguiente Find Objects Buscar objetos + Compare with Image Comparar con imagen First Page Primera página First-line Left Indent Sangría izquierda en primera línea Fisheye… Ojo de pez… @@ -1715,6 +1752,7 @@ font-technology Variable Variables Report copied Informe copiado Line {line} Línea {line} Line {line}: {name}: {status} Línea {line}: {name}: {status} + Read Action Set File Leer archivo de conjunto de acciones Read Shortcut Set File Leer archivo de conjunto de métodos abreviados Import Matches Asociaciones de importación Remember Import Matches Recordar asociaciones de importación @@ -1963,6 +2001,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 @@ -2763,6 +2805,7 @@ digits Malayalam Malayálam Reset Transformation Restablecer transformación Replace With Font Reemplazar con fuente Replace With Font From Reemplazar con fuente de + Find In Buscar en System Sistema Replaces the saved preset of that name. Reemplaza el preajuste guardado con ese nombre. Replace… Reemplazar… @@ -3060,6 +3103,11 @@ digits Malayalam Malayálam Selected Swatches Muestras seleccionadas Selected Swatches Only Solo muestras seleccionadas Selection Selección +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Selección rectangular + Touching Objetos tocados + Fully Enclosed Totalmente incluidos + Drag Direction Dirección de arrastre Selection & Anchor Display Visualización de selección y anclas Selection Only Solo la selección Selection Tool Herramienta Selección @@ -3197,6 +3245,7 @@ digits Malayalam Malayálam Simulate Colored Paper Simular papel de color Simulate Paper Simular papel Double Column Columna doble + Keep Tools Beside the Canvas Herramientas junto al lienzo Single Left Quotation Mark Comilla simple de apertura Single Quotes: Comillas simples: Single Right Quotation Mark Comilla simple de cierre @@ -3445,6 +3494,15 @@ digits Malayalam Malayálam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Ajustes de Tate-chu-yoko… Tate-chu-yoko Settings Ajustes de Tate-chu-yoko + Composite Fonts… Fuentes compuestas… + Composite Fonts Fuentes compuestas + Export Composite Fonts Exportar fuentes compuestas + Import Composite Fonts Importar fuentes compuestas + Delete Font Eliminar fuente + Kanji Kanji + Kana Kana + Numerals Números + Scale at Center Escalar desde el centro Up/Down: Arriba/abajo: Left/Right: Izquierda/derecha: Temperature Temperatura @@ -3498,6 +3556,8 @@ digits Malayalam Malayálam Tile Type: Tipo de mosaico: tile {number} mosaico {number} Tiles are numbered across, then down; leave the range empty for every tile. Los mosaicos se numeran de izquierda a derecha y luego hacia abajo; deja el rango vacío para todos. + Copies per Sheet (as many as fit, in a grid) Copias por hoja (tantas como quepan, en cuadrícula) + Gutter: Medianil: Tint Matiz Tip extends past the end point La punta se extiende más allá del punto final Tip on the end point Punta en el punto final @@ -3792,6 +3852,7 @@ digits Malayalam Malayálam Zoom to Selection Zoom a la selección Zoom Tool Herramienta Zoom Zoom with Mouse Wheel Zoom con la rueda del mouse + Scroll Sideways with Cmd/Ctrl + Wheel Desplazar horizontalmente con Cmd/Ctrl + rueda del mouse Scrub Numeric Fields by Dragging Ajustar campos numéricos arrastrando @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} archivo en {folder} {note}. ¿Mostrar la carpeta del paquete?|{n} archivos en {folder} {note}. ¿Mostrar la carpeta del paquete? @plural {n} linked file not found|{n} linked files not found No se encontró {n} archivo vinculado|No se encontraron {n} archivos vinculados @@ -3961,7 +4022,7 @@ axis Both Ambos Dock to Bottom Anclar abajo Dock Control Bar Anclar barra de control Drag to float the toolbar Arrastrar para hacer flotar la barra de herramientas - Drag to the window's left edge to dock Arrastrar al borde izquierdo de la ventana para anclar + Drag to the window's left or right edge to dock Arrastrar al borde izquierdo o derecho de la ventana para anclar Float Tool Group Hacer flotar el grupo de herramientas # Status and error messages (`@msg`), translated where the status bar shows them; the message itself @@ -3970,7 +4031,7 @@ axis Both Ambos # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` no hay ninguna biblioteca `{id}` @msg select some type first selecciona primero algún texto -@msg kerning must be a number, "auto", or "optical" El interletraje debe ser un número, «auto» u «optical» +@msg kerning must be a number, "auto", or "optical" kerning debe ser un número, «auto» u «optical» @msg the selection has no gradient fill la selección no tiene un degradado de relleno @msg the selection has no gradient stroke la selección no tiene un degradado de trazo @msg the selection has no pattern fill la selección no tiene un motivo de relleno @@ -4074,6 +4135,7 @@ axis Both Ambos @msg Targa files are RGB: colorModel rgb los archivos Targa son RGB: colorModel rgb @msg Text Wrap: select a wrap object Ceñir texto: selecciona un objeto para ceñir @msg This shortcut file is in a form VectorCraft can't read. Este archivo de métodos abreviados tiene un formato que VectorCraft no puede leer. +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` debe ser "none", "sharp", "crisp" o "strong" @msg `aspect` must be a number (%) `aspect` debe ser un número (%) @msg `at` must be [x, y] `at` debe ser [x, y] @msg `color` must be a colour `color` debe ser un color @@ -4197,9 +4259,9 @@ axis Both Ambos @msg bad shortcut atajo no válido @msg bad stream length longitud de flujo no válida @msg bad trailer trailer no válido -@msg balance must be between 0 and 100 el equilibrio debe estar entre 0 y 100 +@msg balance must be between 0 and 100 balance debe estar entre 0 y 100 @msg batch step {_1} (`{id}`) failed: {e} falló el paso {_1} del lote (`{id}`): {e} -@msg brightness must be dark|mediumDark|mediumLight|light el brillo debe ser dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light brightness debe ser dark|mediumDark|mediumLight|light @msg can't encode the document: {0} no se puede codificar el documento: {0} @msg can't parse shortcut `{c}` no se puede interpretar el atajo `{c}` @msg can't parse shortcut `{s}` for `{k}` no se puede interpretar el atajo `{s}` de `{k}` @@ -4224,7 +4286,7 @@ axis Both Ambos @msg compound paths can only contain paths los trazados compuestos solo pueden contener trazados @msg compressed (RLE) bitmaps can't be read yet todavía no se pueden leer mapas de bits comprimidos (RLE) @msg damaged dañado -@msg decimals must be {_1}–{_2} (got {_3}) los decimales deben ser {_1}–{_2} (se recibió {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals debe ser {_1}–{_2} (se recibió {_3}) @msg divide into 1 to {MAX_PIECES} slices divide en 1 a {MAX_PIECES} sectores @msg effect {k} is `{_1}`, not `{id}` el efecto {k} es `{_1}`, no `{id}` @msg empty brush stroke trazo de pincel vacío @@ -4349,6 +4411,8 @@ axis Both Ambos @msg no such action set no existe ese conjunto de acciones @msg the action sets changed; nothing was deleted los conjuntos de acciones han cambiado; no se ha eliminado nada @msg no action set to save no hay ningún conjunto de acciones que guardar +@msg the action set file can't be read in full el archivo de conjunto de acciones no se puede leer completo +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Se cargó “{name}”: se omitió {n} paso|Se cargó “{name}”: se omitieron {n} pasos @msg not an action set file no es un archivo de conjunto de acciones @msg the action set file is larger than 8 MiB el archivo de conjunto de acciones supera los 8 MiB @msg the action set file holds more than 1,000 actions el archivo de conjunto de acciones contiene más de 1000 acciones @@ -4423,7 +4487,7 @@ axis Both Ambos @msg replace: the object to replace was changed or deleted replace: se modificó o eliminó el objeto que se iba a reemplazar @msg pattern missing falta el motivo @msg patterns nested too deeply are filled with mid-grey los motivos anidados a demasiada profundidad se rellenan de gris medio -@msg percent must be above 0 and at most 6400, not {p} el porcentaje debe ser mayor que 0 y como máximo 6400, no {p} +@msg percent must be above 0 and at most 6400, not {p} percent debe ser mayor que 0 y como máximo 6400, no {p} @msg pick a colour, gradient or pattern to save as a swatch elige un color, degradado o motivo para guardarlo como muestra @msg plane must be left, right or ground plane debe ser left, right o ground @msg plug-in ABI: {0} ABI del plugin: {0} @@ -4444,8 +4508,8 @@ axis Both Ambos @msg result would exceed the canvas (coordinates out of range) el resultado excedería el lienzo (coordenadas fuera de rango) @msg objects can't be nested more than 32 deep los objetos no pueden anidarse a más de 32 niveles @msg the SVG's elements are nested more than {0} deep los elementos del SVG están anidados a más de {0} niveles -@msg scale must be a number from {_1} to {_2}, not {_3} la escala debe ser un número de {_1} a {_2}, no {_3} -@msg scale must be a number from {_1} to {_2}, not {s} la escala debe ser un número de {_1} a {_2}, no {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale debe ser un número de {_1} a {_2}, no {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale debe ser un número de {_1} a {_2}, no {s} @msg select (or target) objects whose appearance isn't basic selecciona (o apunta a) objetos cuya apariencia no sea básica @msg select a Live Paint group selecciona un grupo de pintura interactiva @msg select a blend selecciona una fusión @@ -4473,6 +4537,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 @@ -4710,11 +4781,15 @@ axis Both Ambos @msg its objects are nested too deeply sus objetos están anidados a demasiada profundidad @msg its section ending with `%{end}` doesn't end su sección que termina con `%{end}` no termina @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) los pinceles que VectorCraft aún no puede leer se omitieron del panel Pinceles ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet los objetos con varios rellenos o trazos, efectos o pinceles se importaron con su aspecto dibujado ({_1}): su aspecto aún no se puede editar +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet los objetos con varios rellenos o trazos, trazos de ancho variable, efectos o pinceles se importaron con su aspecto dibujado ({_1}): su aspecto aún no se puede editar +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding el nombre `{name}` no está en UTF-8 y se leyó como {label}: si no se ve bien, abre el archivo con otro legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding el nombre `{name}` no está en UTF-8 y partes de él tampoco son {label} válido: abre el archivo con otro legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) se leyeron como {label} más nombres que no están en UTF-8 ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it el degradado “{name}” no está definido en el archivo: lo sustituye un color sólido @msg an image VectorCraft can't read was left out se omitió una imagen que VectorCraft no puede leer @msg graphs come in as plain art, without their data or settings los gráficos se importan como ilustración normal, sin sus datos ni sus ajustes @msg an image whose data is cut short was left out se omitió una imagen con datos incompletos +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers una imagen incrustada de {_1} × {_2} píxeles se redujo a {_3} × {_4} píxeles para conservar las capas @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel las imágenes vinculadas muestran la vista previa de baja resolución guardada en el archivo hasta que se encuentren sus archivos: vuelva a vincularlas en el panel Vínculos @msg linked type areas past VectorCraft's limits were left out se omitieron las áreas de texto enlazadas que superan los límites de VectorCraft @msg type that changes writing direction inside a text object opens in the direction of its first character el texto que cambia de dirección de escritura dentro de un objeto de texto se abre en la dirección de su primer carácter @@ -4776,6 +4851,7 @@ axis Both Ambos @msg object ids are non-negative integers, not {v} los identificadores de objeto son enteros no negativos, no {v} @msg {key} must be an array of non-negative integer object ids {key} debe ser una lista de identificadores de objeto que sean enteros no negativos @msg invalid object id {value}: expected a non-negative integer identificador de objeto no válido {value}: se esperaba un entero no negativo +@msg `{key}` entry {index} is {value}, not a non-negative integer object id la entrada {index} de `{key}` es {value}, no un identificador de objeto (un entero no negativo) @msg no such object id {value} no existe el objeto con el identificador {value} @msg id {value} is a layer, not an object el identificador {value} es una capa, no un objeto @msg id {value} is a layer, not an object; move layers with layer.move el identificador {value} es una capa, no un objeto; mueva las capas con layer.move @@ -4802,12 +4878,19 @@ axis Both Ambos @msg the Pixar file's image data is damaged los datos de imagen del archivo Pixar están dañados @msg the JPEG 2000 file can't be read no se puede leer el archivo JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content el archivo JPEG 2000 tiene un espacio de color o contenido JPX no compatible +@msg the HEIC or HEIF file can't be read no se puede leer el archivo HEIC o HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read el archivo HEIC o HEIF contiene un tipo de imagen que no se puede leer +@msg HEIC and HEIF images can't be opened in this build las imágenes HEIC y HEIF no se pueden abrir en esta compilación @msg the PSD image is too large to read ({_1} × {_2} pixels) la imagen PSD es demasiado grande para leerla ({_1} × {_2} píxeles) @msg zipped 32-bit PSD image data with prediction can't be read no se pueden leer datos de imagen PSD de 32 bits comprimidos en ZIP con predicción @msg {depth}-bit PSD files in this colour mode can't be read los archivos PSD de {depth} bits en este modo de color no se pueden leer @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 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 De-Interlace… Desentrelazar… De-Interlace Desentrelazar @@ -4863,6 +4946,9 @@ axis Both Ambos Bottom Right Abajo a la derecha Edge Borde System Title Bar Barra de título del sistema + VectorCraft Title Bar on Every Desktop Barra de título de VectorCraft en todos los escritorios + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Mientras está definida, la variable de entorno VECTORCRAFT_TITLEBAR elige la barra de título. + System Title Bar takes precedence over this option while it is on. Mientras está activada, Barra de título del sistema tiene prioridad sobre esta opción. Value Axis Eje de valores On Left Side En el lado izquierdo On Right Side En el lado derecho @@ -5452,6 +5538,11 @@ mark-style Japanese Japonesas @msg unknown parameter `{key}`; the command takes: {keys} parámetro desconocido `{key}`; el comando acepta: {keys} Load EPS into Clipboard Cargar EPS en el portapapeles @msg an image of {w} × {h} pixels is too large to draw una imagen de {w} × {h} píxeles es demasiado grande para dibujarse +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: no es una imagen PNG, JPEG, WebP, TIFF ni BMP +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ la imagen mide {iw} × {ih} píxeles y la mesa de trabajo a ese ancho mide {rw} × {rh} píxeles: sus proporciones difieren +@msg `threshold` must be a number from 0 to 1 `threshold` debe ser un número de 0 a 1 +@msg background {v}: a color such as "#808080" fondo {v}: un color como "#808080" +@msg comparing with an image file isn't available in the browser la comparación con un archivo de imagen no está disponible en el navegador @msg an image of {w} × {h} pixels is shown at reduced size una imagen de {w} × {h} píxeles se muestra a tamaño reducido @msg the image can't be read as a texture la imagen no se puede leer como textura Fill and Stroke Controls Controles de relleno y trazo @@ -5646,11 +5737,19 @@ mark-style Japanese Japonesas @msg too many library panels were opened in this session se abrieron demasiados paneles de biblioteca en esta sesión @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. La imagen tiene {_1} × {_2} píxeles, más de {LARGE_TRACE_SIDE} en un lado: se calcará, pero más despacio. Un ajuste preestablecido de menor fidelidad o una copia más pequeña de la imagen se calcan más rápido. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). La imagen se calcó a {_1} × {_2} píxeles, reducida desde {_3} × {_4} ({_5} megapíxeles como máximo). +@msg byColor must be true or false byColor debe ser true o false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor no puede dividir los grupos de Agrupación automática: calque sin Agrupación automática +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor no puede reagrupar un calco con un efecto Pathfinder activo: expanda sin byColor Snapping Ajuste Snap to Tangent Ajustar a tangente Snap to Perpendicular Ajustar a perpendicular @msg unknown snap option `{key}` (use {options}) opción de ajuste desconocida `{key}` (use {options}) Gap Length: Longitud del hueco: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. El modo Logotipo plano no usa keepColors: calca los colores indicados en logoColors o los colores que encuentra. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). No se calcó nada: ninguno de los colores que encontró el calco coincide con keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. No se calcó nada: Ignorar color omite todas las áreas de los colores que indica keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. No se calcó nada: los colores que coinciden con keepColors no producen ningún trazado. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: ninguno de los colores que encontró el calco coincide con {c}, así que no se calca nada de él. @msg `colorGroup` needs `library: "document"` `colorGroup` requiere `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` requiere `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` requiere `mode: "color"` @@ -5762,11 +5861,14 @@ axis Y Axis Eje Y @msg Running “{name}”… Ejecutando “{name}”… @msg a live effect runs while the document is drawn, so it can't be `heavy` un efecto interactivo se ejecuta mientras se dibuja el documento, así que no puede ser `heavy` @msg an exporter always gets an export's budget: leave `budget` out un plugin de exportación siempre recibe el presupuesto de una exportación: omite `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” pide un presupuesto de cálculo grande (cada ejecución hasta {minutes} minutos y {times} veces las instrucciones habituales): instálalo con `allowHeavy: true` cuando el usuario lo acepte +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” pide un presupuesto de cálculo grande (cada ejecución hasta {minutes} minutos y {times} veces las instrucciones habituales): una persona debe aprobarlo en la aplicación de escritorio, o pasa `allowHeavy: true` en una sesión iniciada con --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? ¿Instalar “{name}”, que pide estos permisos? ID: {id} ID: {id} Version: {version} Versión: {version} + Install “{name}” in place of the plug-in with the same ID? ¿Instalar “{name}” en lugar del plugin con el mismo ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Este archivo no es el plugin instalado antes con este ID. Recibe la configuración y la caché guardadas para ese ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” es un módulo distinto del plugin con ID {_1} y recibiría la configuración y la caché guardadas para él: instálalo con `replace: true` cuando el usuario lo acepte File SHA-256: {hash} SHA-256 del archivo: {hash} Read the objects it is given Leer los objetos que recibe Change, add and delete objects in the document Cambiar, añadir y eliminar objetos del documento @@ -5774,21 +5876,43 @@ 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 Show its progress, see Cancel and write log lines Mostrar su progreso, detectar la cancelación y escribir líneas de registro -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permiso `progress` necesita el nivel de API de plugins {PROGRESS_API}: indique `api` {PROGRESS_API} + Keep settings of its own Guardar una configuración propia + Keep a cache of its own, which you can clear Guardar una caché propia, que usted puede vaciar + Clear Plug-in Cache Vaciar caché del plugin +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permiso `{permission}` necesita el nivel de API de plugins {PROGRESS_API}: indique `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} el módulo importa `env.{name}` con un tipo distinto del del host: el del host es {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` el módulo importa `env.{name}`, que un plugin solo puede usar en el nivel de API de plugins {PROGRESS_API} (`api` {PROGRESS_API}) y con el permiso `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network el módulo importa `{from}.{name}`, que el host no ofrece: un plugin solo puede importar `env.vc_progress`, `env.vc_cancelled` y `env.vc_log` (nivel de API de plugins 2), y ningún WASI, archivo ni red +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` el módulo importa `env.{name}`, que un plugin solo puede usar en el nivel de API de plugins {PROGRESS_API} (`api` {PROGRESS_API}) y con el permiso `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network el módulo importa `{from}.{name}`, que el host no ofrece: un plugin solo puede importar las funciones `env.vc_*` que enumera docs/plugins.md (nivel de API de plugins 2), y ningún WASI, archivo ni red @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` may only be true `paintOutput` solo puede ser true +@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}` @msg at most {MAX_PERMISSIONS} `permissions` como máximo {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` el permiso `{shown}` no se aplica a un plugin de tipo `{_1}` @msg permission `{shown}` is listed twice el permiso `{shown}` aparece dos veces +@msg only a filter declares `inputs` solo un filtro declara `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` nombra un tipo de objeto desconocido `{shown}`: use type, image o symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` incluye `{shown}` dos veces +@msg `inputs` needs `api` 2 or later `inputs` requiere `api` 2 o posterior +@msg `inputs` needs the permission `doc.read` `inputs` requiere el permiso `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) presupuesto de memoria (el objeto {_1} necesita {size} bytes de entrada, quedan {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) presupuesto de objetos (más de {MAX_OBJECTS} objetos de solo lectura) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permiso `{shown}` está reservado para una API de plugins posterior; este host no lo concede @msg unknown permission `{shown}` permiso desconocido `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pide los permisos {asked}: instálalo con un `grant` que los incluya cuando el usuario lo acepte +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” pide los permisos {asked}: una persona debe aprobarlos en la aplicación de escritorio, o pasa `grant` en una sesión iniciada con --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` y `allowHeavy` solo tienen efecto en una sesión iniciada con --allow-plugin-grants: aprueba el complemento en la aplicación de escritorio +@msg Only the person at the keyboard can approve installing a plug-in Solo la persona que está en el teclado puede aprobar la instalación de un complemento @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a “{name}” no se le concedió `{p}`, que su resultado necesita: no se cambió nada +@msg a heavy filter runs in the background, so it can't declare `commands` un filtro pesado se ejecuta en segundo plano, así que no puede declarar `commands` +@msg at most {MAX_COMMANDS} `commands` como máximo {MAX_COMMANDS} `commands` +@msg command {name} is declared twice el comando {name} está declarado dos veces +@msg command {name}: `label` must be 1-64 printable characters comando {name}: `label` debe tener 1-64 caracteres imprimibles +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter comando {name}: usa un id de 1-{MAX_COMMAND_ID} caracteres de a-z 0-9 _ que empiece por una letra +@msg command {name}: {e} comando {name}: {e} +@msg only a filter or a live effect declares `commands` solo un filtro o un efecto interactivo declara `commands` +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed el módulo no tiene el SHA-256 que indica `hash`: no se instaló nada # Actions panel: action sets (#3712). New Set… Nuevo conjunto… @@ -6207,6 +6331,8 @@ breakText Glyphs Glifos File + Data Set Name Archivo + nombre del conjunto de datos Data Set Name Nombre del conjunto de datos @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` debe ser `fileNumber`, `fileDataSetName` o `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Cada conjunto de datos se guarda sobre el mismo archivo, que solo conserva el último conjunto de datos. +@msg save the document with Save As before Save and Close guarde el documento con «Guardar como» antes de «Guardar y cerrar» @msg no free data set file name in destination folder No hay un nombre de archivo libre para el conjunto de datos en la carpeta de destino # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6326,9 +6452,20 @@ breakText Glyphs Glifos Reset Symbol Instance Restablecer instancia de símbolo @msg type must be static or dynamic, not {v} type debe ser static o dynamic, no {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` es un símbolo estático: primero establezca su tipo de símbolo en Dinámico +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change De un trazado, trazado compuesto o texto seleccionado en una instancia de símbolo solo se puede cambiar el color sólido del relleno y del trazo @msg path must be a list of child indices path debe ser una lista de índices de hijos @msg no object at path {shown} in the symbol's art no hay ningún objeto en la ruta {shown} del arte del símbolo @msg give fill, stroke, opacity and/or blend indique fill, stroke, opacity y/o blend +@msg {key} cannot be set on a guide {key} no se puede definir en una guía +@msg {key} cannot be set on a blend {key} no se puede definir en una fusión +@msg {key} cannot be set on an envelope {key} no se puede definir en una envolvente +@msg {key} cannot be set on a gradient mesh {key} no se puede definir en una malla de degradado +@msg {key} cannot be set on a Repeat {key} no se puede definir en una repetición +@msg {key} cannot be set on a placed document {key} no se puede definir en un documento colocado +@msg {key} cannot be set on an Intertwine group {key} no se puede definir en un grupo entrelazado +@msg stroke cannot be set on a symbol instance stroke no se puede definir en una instancia de símbolo +@msg fill on a symbol instance must be a solid color or none el fill de una instancia de símbolo debe ser un color sólido o none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} no se puede definir en una imagen sin el efecto Convertir en forma o Contornear objeto @msg pass newName, registration, exportType, nineSlice and/or type indique newName, registration, exportType, nineSlice y/o type Text Flow Flujo de texto By Rows Por filas @@ -6358,6 +6495,7 @@ breakText Glyphs Glifos Create Swatch Legend Crear leyenda de muestras Create Swatch Legend… Crear leyenda de muestras… HEX HEX + Help Bar Barra de ayuda # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Herramientas de texto Text Tweaker… Ajustador de texto… @@ -6423,3 +6561,44 @@ 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 plug-in of kind `{_2}`, not an importer `{_1}` es un plugin de tipo `{_2}`, no un plugin de importación +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` es un plugin de tipo `{_2}`, no un plugin de exportació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` + +# Safe Mode + Safe Mode… Modo seguro… + Safe Mode Modo seguro + Help › Safe Mode… Ayuda › Modo seguro… + Enable on Re-launch Activar en el próximo inicio + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. El último inicio de VectorCraft se detuvo mientras se cargaba el plugin {name}. Puede que no sea la causa. + Disable and Continue Desactivar y continuar + Start Normally Iniciar normalmente + Start in Safe Mode Iniciar en modo seguro + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft está en modo seguro: ningún plugin se carga ni se ejecuta hasta que se inicie normalmente. + Disabled plug-ins Plugins desactivados + No plug-ins are disabled. No hay plugins desactivados. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally El modo seguro está activado: los plugins no se cargan ni se ejecutan hasta que VectorCraft se inicie normalmente +@msg give `id` or `file` (see plugin.safeMode) indica `id` o `file` (consulta plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) ese plugin no está desactivado (consulta plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch No se pudieron guardar los plugins desactivados para el próximo inicio +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Expandir apariencia: un efecto de plugin de la selección no se puede expandir en modo seguro + +# Align to Pixel Grid + Align to Pixel Grid Alinear con cuadrícula de píxeles +@msg only paths and compound paths can be aligned to the pixel grid solo los trazados y los trazados compuestos se pueden alinear con la cuadrícula de píxeles +@msg side is left or right, not `{s}` side es left o right, no `{s}` diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 99594975d..110b30ea9 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -157,6 +157,7 @@ Report copied Raportti kopioitu Line {line} Rivi {line} Line {line}: {name}: {status} Rivi {line}: {name}: {status} + Read Action Set File Lue toimintosarjatiedosto Read Shortcut Set File Lue pikanäppäinjoukkotiedosto Import Matches Tuonnin vastaavuudet Remember Import Matches Muista tuonnin vastaavuudet @@ -194,6 +195,11 @@ Ungroup Pura ryhmitys Lock Lukitse Selection Valinta +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Suorakulmiovalinta + Touching Kosketetut + Fully Enclosed Kokonaan sisällä + Drag Direction Vetosuunta All Artwork Above Kaikki yllä oleva kuvitus Other Layers Muut tasot Unlock All Avaa kaikki lukitukset @@ -331,6 +337,13 @@ Apply Bullets Käytä luettelomerkkejä Numbered List Numeroitu luettelo Apply Numbers Käytä numerointia + Bullets and Numbering… Luettelomerkit ja numerointi… + Numbers Numerot + Level: Taso: + Bullet Character: Luettelomerkki: + Number Style: Numerotyyli: + Left Indent: Vasen sisennys: + First Line Indent: Ensimmäisen rivin sisennys: Convert to Text Muunna tekstiksi Filled Dot Täytetty piste Dash Ajatusviiva @@ -459,6 +472,17 @@ Inflate Täytä ilmalla Select objects, then pick an object type to give them a 3D effect. Valitse objektit ja valitse sitten objektityyppi, niin niille tulee 3D-tehoste. Extrude & Bevel… Pursota ja viistä… + 3D (Classic) 3D (perinteinen) + Extrude & Bevel (Classic)… Pursota ja viistä (perinteinen)… + Surface Pinta + Wireframe Rautalanka + No Shading Ei varjostusta + Plastic Muovi + Highlight Intensity Kiillon voimakkuus + Highlight Size Kiillon koko + Blend Steps Sulautusvaiheet + Shading Color Varjostusväri + Draw Hidden Faces Piirrä piilotetut pinnat Revolve… Pyöräytä… Inflate… Täytä ilmalla… Materials… Materiaalit… @@ -664,6 +688,7 @@ Toolbars Työkalurivit Advanced Lisäasetukset Double Column Kaksi saraketta + Keep Tools Beside the Canvas Työkalut kankaan vieressä Actions Toiminnot Asset Export Resurssien vienti Attributes Määritteet @@ -671,6 +696,11 @@ Color Väri Color Guide Väriopas Color Themes Väriteemat + Color Wheel Väriympyrä + Hue ring Sävyrengas + Saturation and brightness Kylläisyys ja kirkkaus + Color field Värikenttä + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties CSS-ominaisuudet Document Info Asiakirjan tiedot Flattener Preview Litistyksen esikatselu @@ -872,6 +902,9 @@ Stained Glass… Lasimaalaus… Texturizer… Teksturoija… System Title Bar Järjestelmän otsikkorivi + VectorCraft Title Bar on Every Desktop VectorCraftin otsikkorivi kaikissa työpöytäympäristöissä + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Ympäristömuuttuja VECTORCRAFT_TITLEBAR valitsee otsikkorivin niin kauan kuin se on asetettu. + System Title Bar takes precedence over this option while it is on. Järjestelmän otsikkorivi ohittaa tämän asetuksen niin kauan kuin se on käytössä. Exit Lopeta Distort Vääristä Diffuse Glow… Hajautettu hehku… @@ -1234,7 +1267,7 @@ lighting Light Valo X: X: Y: Y: Drag to float the toolbar Kelluta työkaluriviä vetämällä - Drag to the window's left edge to dock Telakoi vetämällä ikkunan vasempaan reunaan + Drag to the window's left or right edge to dock Telakoi vetämällä ikkunan vasempaan tai oikeaan reunaan Crystallize Kiteytä Add Anchor Point Tool Lisää ankkuripiste -työkalu Anchor Point Tool Ankkuripistetyökalu @@ -1488,6 +1521,15 @@ digits Malayalam Malajalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Tate-chu-yoko-asetukset… Tate-chu-yoko Settings Tate-chu-yoko-asetukset + Composite Fonts… Yhdistelmäfontit… + Composite Fonts Yhdistelmäfontit + Export Composite Fonts Vie yhdistelmäfontit + Import Composite Fonts Tuo yhdistelmäfontit + Delete Font Poista fontti + Kanji Kanji + Kana Kana + Numerals Numerot + Scale at Center Skaalaa keskeltä Up/Down: Ylös/alas: Left/Right: Vasen/oikea: Underline Alleviivaus @@ -1856,6 +1898,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 @@ -2617,6 +2663,7 @@ digits Malayalam Malajalam Tile Range: Ruutualue: tile {number} ruutu {number} Tiles are numbered across, then down; leave the range empty for every tile. Ruudut numeroidaan vaakasuunnassa ja sitten alaspäin; jätä alue tyhjäksi, niin kaikki ruudut tulostetaan. + Copies per Sheet (as many as fit, in a grid) Kopioita arkille (niin monta kuin mahtuu, ruudukkoon) Transverse (the page a quarter turn on the paper) Poikittainen (sivu neljänneskierroksen käännettynä paperilla) Unprintable margin: Tulostumaton reunus: {pages} in all, {count} per copy {pages} yhteensä, {count} kopiota kohden @@ -2687,6 +2734,10 @@ digits Malayalam Malajalam < 1 sec < 1 s [Unnamed] [Nimetön] Amount: Määrä: + Anti-aliasing Method Reunanpehmennystapa +antiAlias Sharp Terävä + Crisp Selkeä + Strong Vahva Anti-aliasing: Reunanpehmennys: Artboard: Piirtoalusta: Color Table Väritaulukko @@ -3120,6 +3171,14 @@ theme Appearance Ulkoasu Asset Export Settings Resurssien vientiasetukset Assets Resurssit Export Assets Vie resurssit + Export 3D Object Vie 3D-objekti + 3D Export Format 3D-vientimuoto + Export 3D Assets Vie 3D-resurssit +@msg {what} can't be exported in 3D: {e} kohdetta {what} ei voi viedä 3D-muodossa: {e} +@msg asset {id} isn't a 3D asset resurssi {id} ei ole 3D-resurssi +@msg asset {id} is a 3D asset: assets.export3D writes it resurssi {id} on 3D-resurssi: assets.export3D kirjoittaa sen +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) ei vielä 3D-resursseja: kerää ensin objekti, jossa on 3D-tehoste (assets.add3D) +@msg select an object with a 3D effect (or give its ids) valitse objekti, jossa on 3D-tehoste (tai anna sen tunnukset) Remove Asset Poista resurssi Rename Asset Nimeä resurssi uudelleen Fill Rule Täyttösääntö @@ -3254,6 +3313,7 @@ theme Appearance Ulkoasu Document JSON Asiakirjan JSON Duplicate Monista Find Objects Etsi objekteja + Compare with Image Vertaa kuvaan Inspect Document Tarkastele asiakirjaa Inspect Object Tarkastele objektia Begin Undo Group Aloita kumoamisryhmä @@ -3557,6 +3617,7 @@ theme Appearance Ulkoasu Feet Jalat Files Tiedostot Fill New Type Objects With Placeholder Text Täytä uudet tekstiobjektit paikkamerkkitekstillä + Start Lists From Typed Markers Aloita luettelot kirjoitetuista merkeistä Folder Kansio Font Preview Size Fontin esikatselukoko Get Preferences Hae asetukset @@ -3667,6 +3728,7 @@ theme Appearance Ulkoasu Yards Jaardit Zoom to Selection Zoomaa valintaan Zoom with Mouse Wheel Zoomaa hiiren rullalla + Scroll Sideways with Cmd/Ctrl + Wheel Vieritä vaakasuunnassa Cmd/Ctrl + hiiren rullalla Scrub Numeric Fields by Dragging Muuta numerokenttiä vetämällä Linux Display Server Linuxin näyttöpalvelin Print Preview Tulostuksen esikatselu @@ -3948,6 +4010,7 @@ effect Regular Normaali Find in Folder… Etsi kansiosta… Replace With Font Korvaa fontilla Replace With Font From Korvaavan fontin lähde + Find In Etsi kohteesta System Järjestelmä — substituted by {style} — korvattu tyylillä {style} — {n} characters from another font — {n} merkkiä toisesta fontista @@ -4193,7 +4256,7 @@ imageTrace Outline Ääriviiva @plural {n} stroke|{n} strokes {n} viiva|{n} viivaa @msg no library `{id}` ei kirjastoa `{id}` @msg select some type first valitse ensin tekstiä -@msg kerning must be a number, "auto", or "optical" Parivälistyksen on oltava luku, ”auto” tai ”optical” +@msg kerning must be a number, "auto", or "optical" kerning-arvon on oltava luku, ”auto” tai ”optical” @msg the selection has no gradient fill valinnalla ei ole liukuväritäyttöä @msg the selection has no gradient stroke valinnalla ei ole liukuväriviivaa @msg the selection has no pattern fill valinnalla ei ole kuviotäyttöä @@ -4295,6 +4358,7 @@ imageTrace Outline Ääriviiva @msg Split Into Grid: select paths large enough for the grid Jaa ruudukoksi: valitse ruudukolle riittävän suuret polut @msg Targa files are RGB: colorModel rgb Targa-tiedostot ovat RGB-muotoisia: colorModel rgb @msg Text Wrap: select a wrap object Tekstin rivitys: valitse rivitysobjekti +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` on oltava "none", "sharp", "crisp" tai "strong" @msg `aspect` must be a number (%) `aspect` on oltava luku (%) @msg `at` must be [x, y] `at` on oltava [x, y] @msg `color` must be a colour `color` on oltava väri @@ -4403,7 +4467,7 @@ imageTrace Outline Ääriviiva @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art tai type @msg artboard {0} does not exist piirtoalustaa {0} ei ole olemassa @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard tässä mittakaavassa piirustus on {w} × {h} pt, enemmän kuin kankaalle mahtuu: valitse pienempi mittakaava tai sovita se piirtoalustaan -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` akselin on oltava "vertical", "horizontal" tai kulma asteina, ei `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis-arvon on oltava "vertical", "horizontal" tai kulma asteina, ei `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" tausta {other}: transparent, white, black tai väri, kuten "#ff8800" @msg bad color {v} virheellinen väri {v} @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio on oltava luku väliltä 0,1–10 @@ -4412,9 +4476,9 @@ imageTrace Outline Ääriviiva @msg bad shortcut virheellinen pikanäppäin @msg bad stream length virheellinen virran pituus @msg bad trailer virheellinen trailer -@msg balance must be between 0 and 100 tasapainon on oltava väliltä 0–100 +@msg balance must be between 0 and 100 balance-arvon on oltava väliltä 0–100 @msg batch step {_1} (`{id}`) failed: {e} erävaihe {_1} (`{id}`) epäonnistui: {e} -@msg brightness must be dark|mediumDark|mediumLight|light kirkkauden on oltava dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light brightness-arvon on oltava dark|mediumDark|mediumLight|light @msg can't encode the document: {0} asiakirjaa ei voi koodata: {0} @msg can't parse shortcut `{c}` pikanäppäintä `{c}` ei voi jäsentää @msg can't parse shortcut `{s}` for `{k}` pikanäppäintä `{s}` kohteelle `{k}` ei voi jäsentää @@ -4439,7 +4503,7 @@ imageTrace Outline Ääriviiva @msg compound paths can only contain paths yhdistelmäpolut voivat sisältää vain polkuja @msg compressed (RLE) bitmaps can't be read yet pakattuja (RLE) bittikarttoja ei voi vielä lukea @msg damaged vaurioitunut -@msg decimals must be {_1}–{_2} (got {_3}) desimaalien on oltava {_1}–{_2} (saatiin {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals-arvon on oltava {_1}–{_2} (saatiin {_3}) @msg divide into 1 to {MAX_PIECES} slices jaa 1–{MAX_PIECES} viipaleeseen @msg effect {k} is `{_1}`, not `{id}` tehoste {k} on `{_1}`, ei `{id}` @msg empty brush stroke tyhjä sivellinviiva @@ -4457,9 +4521,9 @@ imageTrace Outline Ääriviiva @msg an image has no pixels and was left out kuva, jossa ei ole pikseleitä, jätettiin pois @msg image data `{_1}` is missing kuvan data `{_1}` puuttuu @msg image would be too large; lower the resolution kuvasta tulisi liian suuri; pienennä tarkkuutta -@msg index must be a number or first, previous, next or last indeksin on oltava luku tai first, previous, next tai last -@msg index must be a number or first, previous, next or last, not `{other}` indeksin on oltava luku tai first, previous, next tai last, ei `{other}` -@msg index must be ≥ 0 indeksi ei voi olla negatiivinen +@msg index must be a number or first, previous, next or last index-arvon on oltava luku tai first, previous, next tai last +@msg index must be a number or first, previous, next or last, not `{other}` index-arvon on oltava luku tai first, previous, next tai last, ei `{other}` +@msg index must be ≥ 0 index-arvon on oltava ≥ 0 @msg installing from a path is not available on the web; pass the module as `dataBase64` polusta asentaminen ei ole käytettävissä verkossa; välitä moduuli muodossa `dataBase64` @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) sisäinen virhe kohteessa `{cmd}`: {msg} (asiakirja jätettiin ennalleen; ilmoita tästä virheestä) @msg internal error: {msg} (please report this bug) sisäinen virhe: {msg} (ilmoita tästä virheestä) @@ -4564,6 +4628,8 @@ imageTrace Outline Ääriviiva @msg no such action set toimintosarjaa ei ole @msg the action sets changed; nothing was deleted toimintosarjat muuttuivat; mitään ei poistettu @msg no action set to save ei tallennettavaa toimintosarjaa +@msg the action set file can't be read in full toimintosarjatiedostoa ei voi lukea kokonaan +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Ladattiin ”{name}”: jätettiin pois {n} vaihe|Ladattiin ”{name}”: jätettiin pois {n} vaihetta @msg not an action set file ei ole toimintosarjatiedosto @msg the action set file is larger than 8 MiB toimintosarjatiedosto on suurempi kuin 8 MiB @msg the action set file holds more than 1,000 actions toimintosarjatiedostossa on yli 1 000 toimintoa @@ -4637,9 +4703,9 @@ imageTrace Outline Ääriviiva @msg replace: the object to replace was changed or deleted replace: korvattavaa objektia on muutettu tai se on poistettu @msg pattern missing kuvio puuttuu @msg patterns nested too deeply are filled with mid-grey liian syvälle sisäkkäiset kuviot täytetään keskiharmaalla -@msg percent must be above 0 and at most 6400, not {p} prosenttiarvon on oltava yli 0 ja enintään 6400, ei {p} +@msg percent must be above 0 and at most 6400, not {p} percent-arvon on oltava yli 0 ja enintään 6400, ei {p} @msg pick a colour, gradient or pattern to save as a swatch valitse väri, liukuväri tai kuvio tallennettavaksi värimalliksi -@msg plane must be left, right or ground tason on oltava left, right tai ground +@msg plane must be left, right or ground plane-arvon on oltava left, right tai ground @msg plug-in ABI: {0} laajennuksen ABI: {0} @msg plug-in exceeded its {0} laajennus ylitti rajan: {0} @msg plug-in failed: {0} laajennus epäonnistui: {0} @@ -4658,8 +4724,8 @@ imageTrace Outline Ääriviiva @msg result would exceed the canvas (coordinates out of range) tulos ylittäisi kankaan (koordinaatit alueen ulkopuolella) @msg objects can't be nested more than 32 deep objekteja ei voi sisäkkäistää yli 32 tason syvyyteen @msg the SVG's elements are nested more than {0} deep elementit ovat SVG-tiedostossa sisäkkäin yli {0} tason syvyydessä -@msg scale must be a number from {_1} to {_2}, not {_3} mittakaavan on oltava luku väliltä {_1}–{_2}, ei {_3} -@msg scale must be a number from {_1} to {_2}, not {s} mittakaavan on oltava luku väliltä {_1}–{_2}, ei {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale-arvon on oltava luku väliltä {_1}–{_2}, ei {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale-arvon on oltava luku väliltä {_1}–{_2}, ei {s} @msg select (or target) objects whose appearance isn't basic valitse (tai kohdista) objektit, joiden ulkoasu ei ole perusulkoasu @msg select a Live Paint group valitse elävän maalauksen ryhmä @msg select a blend valitse sulautus @@ -4681,6 +4747,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 @@ -4918,11 +4991,15 @@ imageTrace Outline Ääriviiva @msg its objects are nested too deeply sen objektit ovat liian syvällä sisäkkäin @msg its section ending with `%{end}` doesn't end sen `%{end}`-merkintään päättyvä osio ei pääty @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) siveltimet, joita VectorCraft ei vielä osaa lukea, jätettiin pois Siveltimet-paneelista ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objektit, joilla on useita täyttöjä tai viivoja, tehosteita tai siveltimiä, tuotiin piirretyn näköisinä ({_1}): niiden ulkoasua ei voi vielä muokata +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objektit, joilla on useita täyttöjä tai viivoja, vaihtelevan levyisiä viivoja, tehosteita tai siveltimiä, tuotiin piirretyn näköisinä ({_1}): niiden ulkoasua ei voi vielä muokata +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding nimi `{name}` ei ole UTF-8:aa, ja se luettiin koodauksella {label}: jos se näyttää väärältä, avaa tiedosto toisella legacyNameEncoding-arvolla +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding nimi `{name}` ei ole UTF-8:aa, eivätkä kaikki sen osat ole kelvollista {label}-koodausta: avaa tiedosto toisella legacyNameEncoding-arvolla +@msg more names that aren't UTF-8 were read as {label} ({count}) muita nimiä, jotka eivät ole UTF-8:aa, luettiin koodauksella {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it liukuväriä “{name}” ei ole määritetty tiedostossa: sen tilalla on tasaväri @msg an image VectorCraft can't read was left out kuva, jota VectorCraft ei voi lukea, jätettiin pois @msg graphs come in as plain art, without their data or settings kaaviot tuodaan tavallisena grafiikkana ilman niiden tietoja ja asetuksia @msg an image whose data is cut short was left out kuva, jonka data on katkennut, jätettiin pois +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers upotettu {_1} × {_2} kuvapisteen kuva pienennettiin kokoon {_3} × {_4} kuvapistettä, jotta tasot säilyvät @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel linkitetyt kuvat näyttävät tiedostoon tallennetun matalan tarkkuuden esikatselun, kunnes niiden tiedostot löytyvät: linkitä ne uudelleen Linkit-paneelissa @msg linked type areas past VectorCraft's limits were left out VectorCraftin rajat ylittävät linkitetyt tekstialueet jätettiin pois @msg type that changes writing direction inside a text object opens in the direction of its first character tekstiobjektin sisällä kirjoitussuuntaa vaihtava teksti avautuu ensimmäisen merkkinsä suuntaan @@ -4958,6 +5035,7 @@ imageTrace Outline Ääriviiva @msg object ids are non-negative integers, not {v} objektitunnukset ovat ei-negatiivisia kokonaislukuja, ei {v} @msg {key} must be an array of non-negative integer object ids {key} on oltava taulukko objektitunnuksia, jotka ovat ei-negatiivisia kokonaislukuja @msg invalid object id {value}: expected a non-negative integer virheellinen objektitunnus {value}: odotettiin ei-negatiivista kokonaislukua +@msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}`-luettelon kohde {index} on {value}, ei objektitunnus (ei-negatiivinen kokonaisluku) @msg no such object id {value} objektia tunnuksella {value} ei ole @msg id {value} is a layer, not an object tunnus {value} on taso, ei objekti @msg id {value} is a layer, not an object; move layers with layer.move tunnus {value} on taso, ei objekti; siirrä tasoja komennolla layer.move @@ -4984,12 +5062,19 @@ imageTrace Outline Ääriviiva @msg the Pixar file's image data is damaged Pixar-tiedoston kuvadata on vahingoittunut @msg the JPEG 2000 file can't be read tätä JPEG 2000 -tiedostoa ei voi lukea @msg the JPEG 2000 file has an unsupported color space or JPX content tässä JPEG 2000 -tiedostossa on tukematon väriavaruus tai JPX-sisältö +@msg the HEIC or HEIF file can't be read tätä HEIC- tai HEIF-tiedostoa ei voi lukea +@msg the HEIC or HEIF file holds a kind of image that can't be read tässä HEIC- tai HEIF-tiedostossa on kuvatyyppi, jota ei voi lukea +@msg HEIC and HEIF images can't be opened in this build HEIC- ja HEIF-kuvia ei voi avata tässä versiossa @msg the PSD image is too large to read ({_1} × {_2} pixels) tämä PSD-kuva on liian suuri luettavaksi ({_1} × {_2} pikseliä) @msg zipped 32-bit PSD image data with prediction can't be read ennustusta käyttävää zip-pakattua 32-bittistä PSD-kuvadataa ei voi lukea @msg {depth}-bit PSD files in this colour mode can't be read tässä väritilassa olevia {depth}-bittisiä PSD-tiedostoja ei voi lukea @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 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} @msg Invalid panel layout; restored the default dock Virheellinen paneeliasettelu; oletustelakka palautettiin @msg action is required action vaaditaan @@ -4997,7 +5082,7 @@ imageTrace Outline Ääriviiva @msg before must name a panel before-arvon on nimettävä paneeli @msg docking action contains an unknown panel or an excessive split path telakointitoiminto sisältää tuntemattoman paneelin tai liian pitkän jakopolun @msg group must be true or false group-arvon on oltava true tai false -@msg panel is not in a dock group paneeli ei ole telakkaryhmässä +@msg panel is not in a dock group panel ei ole telakkaryhmässä @msg only a dock group above or below others collapses, not one beside another vain muiden ylä- tai alapuolella oleva telakkaryhmä kutistuu, ei toisen vieressä oleva @msg icon must be true or false icon-arvon on oltava true tai false @msg icon only docks a panel in the icon column, with no anchor icon telakoi paneelin vain kuvakesarakkeeseen, ilman anchor-arvoa @@ -5396,6 +5481,11 @@ mark-style Japanese Japanilainen @msg unknown parameter `{key}`; the command takes: {keys} tuntematon parametri `{key}`; komennon parametrit: {keys} Load EPS into Clipboard Lataa EPS leikepöydälle @msg an image of {w} × {h} pixels is too large to draw kuva, jonka koko on {w} × {h} pikseliä, on liian suuri piirrettäväksi +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: ei ole PNG-, JPEG-, WebP-, TIFF- tai BMP-kuva +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ kuva on {iw} × {ih} pikseliä ja piirtoalusta tällä leveydellä {rw} × {rh} pikseliä: niiden mittasuhteet eroavat +@msg `threshold` must be a number from 0 to 1 `threshold` on oltava luku väliltä 0–1 +@msg background {v}: a color such as "#808080" tausta {v}: väri, kuten "#808080" +@msg comparing with an image file isn't available in the browser vertailu kuvatiedostoon ei ole käytettävissä selaimessa @msg an image of {w} × {h} pixels is shown at reduced size kuva, jonka koko on {w} × {h} pikseliä, näytetään pienennettynä Fill and Stroke Controls Täytön ja viivan säätimet Coloring Controls Värityksen säätimet @@ -5668,11 +5758,19 @@ mark-style Japanese Japanilainen @msg too many library panels were opened in this session tässä istunnossa on avattu liian monta kirjastopaneelia @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Kuva on {_1} × {_2} kuvapistettä, yhdeltä sivultaan yli {LARGE_TRACE_SIDE}: se jäljitetään, mutta hitaammin. Pienemmän tarkkuuden esiasetus tai kuvan pienempi kopio jäljittyy nopeammin. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Kuva jäljitettiin kokoon {_1} × {_2} kuvapistettä pienennettynä koosta {_3} × {_4} (enintään {_5} megapikseliä). +@msg byColor must be true or false byColor-arvon on oltava true tai false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor ei voi jakaa Automaattisen ryhmittelyn ryhmiä: jäljitä ilman Automaattista ryhmittelyä +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor ei voi ryhmitellä uudelleen jäljitystä, jossa on aktiivinen Pathfinder-tehoste: laajenna ilman byColor-asetusta Snapping Tartunta Snap to Tangent Kohdista tangenttiin Snap to Perpendicular Kohdista kohtisuoraan @msg unknown snap option `{key}` (use {options}) tuntematon tartuntavalinta `{key}` (käytä: {options}) Gap Length: Aukon pituus: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Tasainen logo -tila ei käytä keepColors-asetusta: se jäljittää logoColors-asetuksessa nimetyt värit tai löytämänsä värit. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Mitään ei jäljitetty: mikään jäljityksen löytämistä väreistä ei vastaa keepColors-asetusta ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Mitään ei jäljitetty: Ohita väri jättää pois kaikki keepColors-asetuksen värien alueet. +@msg Nothing was traced: the colors that match keepColors produce no paths. Mitään ei jäljitetty: keepColors-asetusta vastaavista väreistä ei synny polkuja. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: mikään jäljityksen löytämistä väreistä ei vastaa väriä {c}, joten siitä ei jäljitetä mitään. @msg `colorGroup` needs `library: "document"` `colorGroup` edellyttää arvoa `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` edellyttää arvoa `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` edellyttää arvoa `mode: "color"` @@ -5851,11 +5949,14 @@ axis Y Axis Y-akseli @msg Running “{name}”… Suoritetaan ”{name}”… @msg a live effect runs while the document is drawn, so it can't be `heavy` live-tehoste suoritetaan asiakirjaa piirrettäessä, joten se ei voi olla `heavy` @msg an exporter always gets an export's budget: leave `budget` out vientilaajennus saa aina viennin budjetin: jätä `budget` pois -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees ”{_1}” pyytää suurta laskentabudjettia (jokainen ajo enintään {minutes} minuuttia ja {times} kertaa tavallinen määrä käskyjä): asenna se asetuksella `allowHeavy: true`, kun käyttäjä hyväksyy +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants ”{_1}” pyytää suurta laskentabudjettia (jokainen ajo enintään {minutes} minuuttia ja {times} kertaa tavallinen määrä käskyjä): ihmisen on hyväksyttävä se työpöytäsovelluksessa, tai anna `allowHeavy: true` istunnossa, joka on käynnistetty valitsimella --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Asennetaanko ”{name}”, joka pyytää näitä käyttöoikeuksia? ID: {id} Tunnus: {id} Version: {version} Versio: {version} + Install “{name}” in place of the plug-in with the same ID? Asennetaanko ”{name}” samaa tunnusta käyttävän laajennuksen tilalle? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Tämä tiedosto ei ole laajennus, joka oli aiemmin asennettu tällä tunnuksella. Se saa tälle tunnukselle säilytetyt asetukset ja välimuistin. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees ”{name}” on eri moduuli kuin laajennus, jonka tunnus on {_1}, ja se saisi sille säilytetyt asetukset ja välimuistin: asenna se asetuksella `replace: true`, kun käyttäjä hyväksyy File SHA-256: {hash} Tiedoston SHA-256: {hash} Read the objects it is given Lukea sille annetut objektit Change, add and delete objects in the document Muuttaa, lisätä ja poistaa asiakirjan objekteja @@ -5863,21 +5964,43 @@ 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 Show its progress, see Cancel and write log lines Näyttää edistymisensä, havaita peruutuksen ja kirjoittaa lokirivejä -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} käyttöoikeus `progress` vaatii laajennus-API:n tason {PROGRESS_API}: anna `api` {PROGRESS_API} + Keep settings of its own Säilyttää omat asetuksensa + Keep a cache of its own, which you can clear Säilyttää oman välimuistin, jonka voit tyhjentää + Clear Plug-in Cache Tyhjennä laajennuksen välimuisti +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} käyttöoikeus `{permission}` vaatii laajennus-API:n tason {PROGRESS_API}: anna `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} moduuli tuo kohteen `env.{name}` eri tyyppisenä kuin isäntä: isännän tyyppi on {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` moduuli tuo kohteen `env.{name}`, jota laajennus saa käyttää vain laajennus-API:n tasolla {PROGRESS_API} (`api` {PROGRESS_API}) ja käyttöoikeudella `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network moduuli tuo kohteen `{from}.{name}`, jota isäntä ei tarjoa: laajennus saa tuoda vain kohteet `env.vc_progress`, `env.vc_cancelled` ja `env.vc_log` (laajennus-API:n taso 2) eikä mitään WASIa, tiedostoja tai verkkoa +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` moduuli tuo kohteen `env.{name}`, jota laajennus saa käyttää vain laajennus-API:n tasolla {PROGRESS_API} (`api` {PROGRESS_API}) ja käyttöoikeudella `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network moduuli tuo kohteen `{from}.{name}`, jota isäntä ei tarjoa: laajennus saa tuoda vain docs/plugins.md-tiedostossa luetellut `env.vc_*`-funktiot (laajennus-API:n taso 2) eikä mitään WASIa, tiedostoja tai verkkoa @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` may only be true `paintOutput` voi olla vain true +@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}` @msg at most {MAX_PERMISSIONS} `permissions` enintään {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` käyttöoikeus `{shown}` ei koske lajin `{_1}` laajennusta @msg permission `{shown}` is listed twice käyttöoikeus `{shown}` on lueteltu kahdesti +@msg only a filter declares `inputs` vain suodatin voi määrittää `inputs`-avaimen +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` nimeää tuntemattoman objektilajin `{shown}`: käytä arvoa type, image tai symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` luettelee kohteen `{shown}` kahdesti +@msg `inputs` needs `api` 2 or later `inputs` edellyttää `api`-tasoa 2 tai uudempaa +@msg `inputs` needs the permission `doc.read` `inputs` edellyttää käyttöoikeutta `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) muistibudjetti (objekti {_1} tarvitsee {size} tavua syötettä, jäljellä {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) objektibudjetti (yli {MAX_OBJECTS} vain luku -objektia) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it käyttöoikeus `{shown}` on varattu myöhemmälle laajennus-API:lle; tämä isäntä ei myönnä sitä @msg unknown permission `{shown}` tuntematon käyttöoikeus `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” pyytää käyttöoikeuksia {asked}: asenna se `grant`-luettelolla, jossa ne ovat, kun käyttäjä suostuu +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants ”{name}” pyytää käyttöoikeuksia {asked}: ihmisen on hyväksyttävä ne työpöytäsovelluksessa, tai anna `grant` istunnossa, joka on käynnistetty valitsimella --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` ja `allowHeavy` toimivat vain istunnossa, joka on käynnistetty valitsimella --allow-plugin-grants: hyväksy laajennus työpöytäsovelluksessa +@msg Only the person at the keyboard can approve installing a plug-in Vain näppäimistön ääressä oleva ihminen voi hyväksyä laajennuksen asennuksen @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” ei saanut käyttöoikeutta `{p}`, jota sen tulos tarvitsee: mitään ei muutettu +@msg a heavy filter runs in the background, so it can't declare `commands` raskas suodin suoritetaan taustalla, joten se ei voi määritellä `commands`-kohtaa +@msg at most {MAX_COMMANDS} `commands` enintään {MAX_COMMANDS} `commands` +@msg command {name} is declared twice komento {name} on määritelty kahdesti +@msg command {name}: `label` must be 1-64 printable characters komento {name}: `label`-kentässä on oltava 1-64 tulostettavaa merkkiä +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter komento {name}: käytä 1-{MAX_COMMAND_ID} merkin tunnusta merkeistä a-z 0-9 _, joka alkaa kirjaimella +@msg command {name}: {e} komento {name}: {e} +@msg only a filter or a live effect declares `commands` vain suodin tai live-tehoste määrittelee `commands`-kohdan +@msg “{name}” › {label}: {e} ”{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed moduulilla ei ole `hash`-kentän SHA-256-arvoa: mitään ei asennettu # Actions panel: action sets (#3712). New Set… Uusi sarja… @@ -6295,6 +6418,8 @@ breakText Glyphs Merkit File + Data Set Name Tiedosto + tietojoukon nimi Data Set Name Tietojoukon nimi @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming`-arvon on oltava `fileNumber`, `fileDataSetName` tai `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Jokainen tietojoukko tallennetaan saman tiedoston päälle, johon jää vain viimeinen tietojoukko. +@msg save the document with Save As before Save and Close tallenna asiakirja komennolla Tallenna nimellä ennen kuin valitset Tallenna ja sulje @msg no free data set file name in destination folder Kohdekansiossa ei ole vapaata tietojoukon tiedostonimeä # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6414,9 +6539,20 @@ breakText Glyphs Merkit Reset Symbol Instance Palauta symboli-ilmentymä @msg type must be static or dynamic, not {v} type-arvon on oltava static tai dynamic, ei {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` on staattinen symboli: aseta ensin sen symbolin tyypiksi Dynaaminen +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Symboli-ilmentymästä valitusta polusta, yhdistelmäpolusta tai tekstistä voi muuttaa vain täytön ja viivan yhtenäistä väriä @msg path must be a list of child indices path-arvon on oltava luettelo alikohteiden indeksejä @msg no object at path {shown} in the symbol's art symbolin kuvituksessa ei ole objektia polussa {shown} @msg give fill, stroke, opacity and/or blend anna fill, stroke, opacity ja/tai blend +@msg {key} cannot be set on a guide {key}-arvoa ei voi asettaa apuviivalle +@msg {key} cannot be set on a blend {key}-arvoa ei voi asettaa sulautukselle +@msg {key} cannot be set on an envelope {key}-arvoa ei voi asettaa kuorelle +@msg {key} cannot be set on a gradient mesh {key}-arvoa ei voi asettaa liukuväriverkolle +@msg {key} cannot be set on a Repeat {key}-arvoa ei voi asettaa toistolle +@msg {key} cannot be set on a placed document {key}-arvoa ei voi asettaa sijoitetulle dokumentille +@msg {key} cannot be set on an Intertwine group {key}-arvoa ei voi asettaa lomitetulle ryhmälle +@msg stroke cannot be set on a symbol instance stroke-arvoa ei voi asettaa symbolin ilmentymälle +@msg fill on a symbol instance must be a solid color or none symbolin ilmentymän fill-arvon on oltava tasainen väri tai none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key}-arvoa ei voi asettaa kuvalle, jolla ei ole tehostetta Muunna muodoksi tai Muunna objekti ääriviivoiksi @msg pass newName, registration, exportType, nineSlice and/or type anna newName, registration, exportType, nineSlice ja/tai type Text Flow Tekstin kulku By Rows Riveittäin @@ -6446,6 +6582,7 @@ breakText Glyphs Merkit Create Swatch Legend Luo värimallien selite Create Swatch Legend… Luo värimallien selite… HEX HEX + Help Bar Ohjepalkki # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Tekstityökalut Text Tweaker… Tekstin säätö… @@ -6516,3 +6653,44 @@ 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 plug-in of kind `{_2}`, not an importer `{_1}` on lajin `{_2}` laajennus, ei tuontilaajennus +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` on lajin `{_2}` laajennus, ei vientilaajennus +@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 + +# Safe Mode + Safe Mode… Vikasietotila… + Safe Mode Vikasietotila + Help › Safe Mode… Ohje › Vikasietotila… + Enable on Re-launch Ota käyttöön seuraavassa käynnistyksessä + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. VectorCraftin edellinen käynnistys pysähtyi, kun laajennusta {name} ladattiin. Se ei välttämättä ole syy. + Disable and Continue Poista käytöstä ja jatka + Start Normally Käynnistä normaalisti + Start in Safe Mode Käynnistä vikasietotilassa + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft on vikasietotilassa: laajennuksia ei ladata eikä suoriteta ennen kuin se käynnistetään normaalisti. + Disabled plug-ins Käytöstä poistetut laajennukset + No plug-ins are disabled. Yhtään laajennusta ei ole poistettu käytöstä. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Vikasietotila on käytössä: laajennuksia ei ladata eikä suoriteta ennen kuin VectorCraft käynnistetään normaalisti +@msg give `id` or `file` (see plugin.safeMode) anna `id` tai `file` (ks. plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) laajennusta ei ole poistettu käytöstä (ks. plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch Käytöstä poistettuja laajennuksia ei voitu tallentaa seuraavaa käynnistystä varten +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Laajenna ulkoasu: valinnan laajennustehostetta ei voi laajentaa vikasietotilassa + +# Align to Pixel Grid + Align to Pixel Grid Tasaa pikseliruudukkoon +@msg only paths and compound paths can be aligned to the pixel grid vain polut ja yhdistelmäpolut voi tasata pikseliruudukkoon +@msg side is left or right, not `{s}` side on left tai right, ei `{s}` diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1aebc348d..0e1632b04 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -199,6 +199,10 @@ Anti-Alias Lissage Anti-alias Rasters Lisser les images pixellisées Anti-aliased Artwork Illustration lissée + Anti-aliasing Method Méthode de lissage +antiAlias Sharp Net + Crisp Précis + Strong Fort Anti-aliasing: Lissage : Any Kind Tout type Font Technology Technologie de police @@ -368,6 +372,13 @@ font-technology Variable Variables Apply Bullets Appliquer les puces Numbered List Liste numérotée Apply Numbers Appliquer la numérotation + Bullets and Numbering… Puces et numérotation… + Numbers Numéros + Level: Niveau : + Bullet Character: Caractère de puce : + Number Style: Style de numéro : + Left Indent: Retrait gauche : + First Line Indent: Retrait de première ligne : Convert to Text Convertir en texte Filled Dot Point plein Dash Tiret @@ -556,6 +567,11 @@ font-technology Variable Variables Color Table ({n} colors) Table des couleurs ({n} couleurs) Color Theme Thème de couleurs Color Themes Thèmes de couleurs + Color Wheel Roue chromatique + Hue ring Anneau de teinte + Saturation and brightness Saturation et luminosité + Color field Champ de couleur + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Type de couleur : Color: Couleur : Colorize Coloriser @@ -1081,6 +1097,14 @@ font-technology Variable Variables Export As SVG… Exporter au format SVG… Export Asset Exporter la ressource Export Assets Exporter les ressources + Export 3D Object Exporter l’objet 3D + 3D Export Format Format d’exportation 3D + Export 3D Assets Exporter les ressources 3D +@msg {what} can't be exported in 3D: {e} impossible d’exporter {what} en 3D : {e} +@msg asset {id} isn't a 3D asset la ressource {id} n’est pas une ressource 3D +@msg asset {id} is a 3D asset: assets.export3D writes it la ressource {id} est une ressource 3D : assets.export3D l’écrit +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) aucune ressource 3D pour l’instant : rassemblez d’abord un objet doté d’un effet 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) sélectionnez un objet doté d’un effet 3D (ou indiquez ses identifiants) Export As… Exporter sous… Export CSS Exporter le CSS Export CSS… Exporter le CSS… @@ -1112,6 +1136,17 @@ font-technology Variable Variables Exporting {} Exportation de {} Export… Exporter… Extrude & Bevel… Extrusion et biseautage… + 3D (Classic) 3D (classique) + Extrude & Bevel (Classic)… Extrusion et biseautage (classique)… + Surface Surface + Wireframe Fil de fer + No Shading Sans ombrage + Plastic Plastique + Highlight Intensity Intensité de la brillance + Highlight Size Taille de la brillance + Blend Steps Étapes de transition + Shading Color Couleur d’ombrage + Draw Hidden Faces Dessiner les faces cachées Eyedropper Pipette Sample Color from Screen Prélever une couleur à l’écran This system has no screen color sampler Ce système n’a pas d’outil de prélèvement de couleur à l’écran @@ -1168,6 +1203,7 @@ font-technology Variable Variables Stroke When Editable Contour quand il est modifiable Fill Color Couleur de fond Fill New Type Objects With Placeholder Text Remplir les nouveaux objets texte avec un texte de substitution + Start Lists From Typed Markers Commencer des listes à partir des marqueurs saisis Fill Rule Règle de remplissage Fill with Placeholder Text Remplir avec un texte de substitution Fill: Fond : @@ -1178,6 +1214,7 @@ font-technology Variable Variables Find Font… Rechercher une police… Find Next Rechercher le suivant Find Objects Rechercher des objets + Compare with Image Comparer à une image First Page Première page First-line Left Indent Retrait gauche de première ligne Fisheye… Fish-eye… @@ -1709,6 +1746,7 @@ font-technology Variable Variables Report copied Rapport copié Line {line} Ligne {line} Line {line}: {name}: {status} Ligne {line}, {name}, {status} + Read Action Set File Lire un fichier de jeu de scripts Read Shortcut Set File Lire un fichier de jeu de raccourcis Import Matches Correspondances d’importation Remember Import Matches Mémoriser les correspondances d’importation @@ -1957,6 +1995,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 @@ -2755,6 +2797,7 @@ digits Malayalam Malayalam Reset Transformation Réinitialiser la transformation Replace With Font Remplacer par la police Replace With Font From Remplacer par une police de + Find In Rechercher dans System Système Replaces the saved preset of that name. Remplace le paramètre prédéfini enregistré sous ce nom. Replace… Remplacer… @@ -3052,6 +3095,11 @@ digits Malayalam Malayalam Selected Swatches Nuances sélectionnées Selected Swatches Only Nuances sélectionnées uniquement Selection Sélection +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Sélection rectangulaire + Touching Objets touchés + Fully Enclosed Entièrement inclus + Drag Direction Sens du glissement Selection & Anchor Display Affichage de la sélection et des points d’ancrage Selection Only Sélection uniquement Selection Tool Outil Sélection @@ -3189,6 +3237,7 @@ digits Malayalam Malayalam Simulate Colored Paper Simuler un papier de couleur Simulate Paper Simuler le papier Double Column Deux colonnes + Keep Tools Beside the Canvas Outils à côté de la zone de travail Single Left Quotation Mark Guillemet simple ouvrant Single Quotes: Guillemets simples : Single Right Quotation Mark Guillemet simple fermant @@ -3437,6 +3486,15 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Paramètres Tate-chu-yoko… Tate-chu-yoko Settings Paramètres Tate-chu-yoko + Composite Fonts… Polices composites… + Composite Fonts Polices composites + Export Composite Fonts Exporter des polices composites + Import Composite Fonts Importer des polices composites + Delete Font Supprimer la police + Kanji Kanji + Kana Kana + Numerals Chiffres + Scale at Center Mettre à l’échelle depuis le centre Up/Down: Haut/bas : Left/Right: Gauche/droite : Temperature Température @@ -3489,6 +3547,7 @@ digits Malayalam Malayalam Tile Range: Plage de carreaux : Tile Type: Type de carreau : tile {number} carreau {number} + Copies per Sheet (as many as fit, in a grid) Copies par feuille (autant qu’il en tient, en grille) Tiles are numbered across, then down; leave the range empty for every tile. Les carreaux sont numérotés de gauche à droite, puis de haut en bas ; laissez la plage vide pour tous les carreaux. Tint Teinte Tip extends past the end point La pointe dépasse le point d’extrémité @@ -3784,6 +3843,7 @@ digits Malayalam Malayalam Zoom to Selection Zoom sur la sélection Zoom Tool Outil Zoom Zoom with Mouse Wheel Zoom avec la molette de la souris + Scroll Sideways with Cmd/Ctrl + Wheel Défilement horizontal avec Cmd/Ctrl + molette Scrub Numeric Fields by Dragging Modifier les champs numériques en faisant glisser @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} fichier dans {folder} {note}. Afficher le dossier de l’assemblage ?|{n} fichiers dans {folder} {note}. Afficher le dossier de l’assemblage ? @plural {n} linked file not found|{n} linked files not found {n} fichier lié introuvable|{n} fichiers liés introuvables @@ -3951,7 +4011,7 @@ axis Both Les deux Dock to Bottom Ancrer en bas Dock Control Bar Ancrer la barre de contrôle Drag to float the toolbar Faire glisser pour rendre la barre d’outils flottante - Drag to the window's left edge to dock Faire glisser vers le bord gauche de la fenêtre pour ancrer + Drag to the window's left or right edge to dock Faire glisser vers le bord gauche ou droit de la fenêtre pour ancrer Float Tool Group Rendre le groupe d’outils flottant # Status and error messages (`@msg`), translated where the status bar shows them; the message itself @@ -3960,7 +4020,7 @@ axis Both Les deux # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` aucune bibliothèque `{id}` @msg select some type first sélectionnez d’abord du texte -@msg kerning must be a number, "auto", or "optical" Le crénage doit être un nombre, « auto » ou « optical » +@msg kerning must be a number, "auto", or "optical" kerning doit être un nombre, « auto » ou « optical » @msg the selection has no gradient fill la sélection n’a pas de dégradé de fond @msg the selection has no gradient stroke la sélection n’a pas de dégradé de contour @msg the selection has no pattern fill la sélection n’a pas de motif de fond @@ -4064,6 +4124,7 @@ axis Both Les deux @msg Targa files are RGB: colorModel rgb Les fichiers Targa sont en RVB : colorModel rgb @msg Text Wrap: select a wrap object Habillage de texte : sélectionnez un objet d’habillage @msg This shortcut file is in a form VectorCraft can't read. Ce fichier de raccourcis est dans un format que VectorCraft ne sait pas lire. +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` doit être "none", "sharp", "crisp" ou "strong" @msg `aspect` must be a number (%) `aspect` doit être un nombre (%) @msg `at` must be [x, y] `at` doit être [x, y] @msg `color` must be a colour `color` doit être une couleur @@ -4187,9 +4248,9 @@ axis Both Les deux @msg bad shortcut raccourci non valide @msg bad stream length longueur de flux non valide @msg bad trailer trailer non valide -@msg balance must be between 0 and 100 l’équilibre doit être compris entre 0 et 100 +@msg balance must be between 0 and 100 balance doit être compris entre 0 et 100 @msg batch step {_1} (`{id}`) failed: {e} l’étape {_1} du lot (`{id}`) a échoué : {e} -@msg brightness must be dark|mediumDark|mediumLight|light la luminosité doit valoir dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light brightness doit valoir dark|mediumDark|mediumLight|light @msg can't encode the document: {0} impossible d’encoder le document : {0} @msg can't parse shortcut `{c}` impossible d’interpréter le raccourci `{c}` @msg can't parse shortcut `{s}` for `{k}` impossible d’interpréter le raccourci `{s}` pour `{k}` @@ -4214,7 +4275,7 @@ axis Both Les deux @msg compound paths can only contain paths les tracés transparents ne peuvent contenir que des tracés @msg compressed (RLE) bitmaps can't be read yet les images bitmap compressées (RLE) ne peuvent pas encore être lues @msg damaged endommagé -@msg decimals must be {_1}–{_2} (got {_3}) le nombre de décimales doit être compris entre {_1} et {_2} (reçu : {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals doit être compris entre {_1} et {_2} (reçu : {_3}) @msg divide into 1 to {MAX_PIECES} slices divisez en 1 à {MAX_PIECES} tranches @msg effect {k} is `{_1}`, not `{id}` l’effet {k} est `{_1}`, pas `{id}` @msg empty brush stroke tracé de forme vide @@ -4339,6 +4400,8 @@ axis Both Les deux @msg no such action set jeu de scripts inexistant @msg the action sets changed; nothing was deleted les jeux de scripts ont changé ; rien n’a été supprimé @msg no action set to save aucun jeu de scripts à enregistrer +@msg the action set file can't be read in full le fichier de jeu de scripts ne peut pas être lu en entier +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out « {name} » chargé : {n} étape a été ignorée|« {name} » chargé : {n} étapes ont été ignorées @msg not an action set file ce n’est pas un fichier de jeu de scripts @msg the action set file is larger than 8 MiB le fichier de jeu de scripts dépasse 8 Mio @msg the action set file holds more than 1,000 actions le fichier de jeu de scripts contient plus de 1 000 scripts @@ -4413,7 +4476,7 @@ axis Both Les deux @msg replace: the object to replace was changed or deleted replace : l’objet à remplacer a été modifié ou supprimé @msg pattern missing motif manquant @msg patterns nested too deeply are filled with mid-grey les motifs imbriqués trop profondément sont remplis de gris moyen -@msg percent must be above 0 and at most 6400, not {p} le pourcentage doit être supérieur à 0 et au plus égal à 6400, pas {p} +@msg percent must be above 0 and at most 6400, not {p} percent doit être supérieur à 0 et au plus égal à 6400, pas {p} @msg pick a colour, gradient or pattern to save as a swatch choisissez une couleur, un dégradé ou un motif à enregistrer comme nuance @msg plane must be left, right or ground plane doit valoir left, right ou ground @msg plug-in ABI: {0} ABI du plug-in : {0} @@ -4434,8 +4497,8 @@ axis Both Les deux @msg result would exceed the canvas (coordinates out of range) le résultat dépasserait la zone de travail (coordonnées hors limites) @msg objects can't be nested more than 32 deep les objets ne peuvent pas être imbriqués sur plus de 32 niveaux @msg the SVG's elements are nested more than {0} deep les éléments du SVG sont imbriqués sur plus de {0} niveaux -@msg scale must be a number from {_1} to {_2}, not {_3} l’échelle doit être un nombre compris entre {_1} et {_2}, pas {_3} -@msg scale must be a number from {_1} to {_2}, not {s} l’échelle doit être un nombre compris entre {_1} et {_2}, pas {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale doit être un nombre compris entre {_1} et {_2}, pas {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale doit être un nombre compris entre {_1} et {_2}, pas {s} @msg select (or target) objects whose appearance isn't basic sélectionnez (ou ciblez) des objets dont l’aspect n’est pas basique @msg select a Live Paint group sélectionnez un groupe de peinture dynamique @msg select a blend sélectionnez un dégradé de formes @@ -4463,6 +4526,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 @@ -4698,11 +4768,15 @@ axis Both Les deux @msg its objects are nested too deeply leurs objets sont imbriqués trop profondément @msg its section ending with `%{end}` doesn't end leur section qui se termine par `%{end}` ne se termine pas @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) les formes que VectorCraft ne sait pas encore lire ont été omises du panneau Formes ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet les objets à plusieurs fonds ou contours, effets ou formes ont été importés tels qu’ils sont dessinés ({_1}) : leur aspect n’est pas encore modifiable +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet les objets à plusieurs fonds ou contours, contours de largeur variable, effets ou formes ont été importés tels qu’ils sont dessinés ({_1}) : leur aspect n’est pas encore modifiable +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding le nom `{name}` n’est pas en UTF-8 et a été lu en {label} : s’il semble incorrect, ouvrez le fichier avec un autre legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding le nom `{name}` n’est pas en UTF-8, et certaines parties ne sont pas non plus du {label} valide : ouvrez le fichier avec un autre legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) d’autres noms qui ne sont pas en UTF-8 ont été lus en {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it le dégradé « {name} » n’est pas défini dans le fichier : une couleur unie le remplace @msg an image VectorCraft can't read was left out une image que VectorCraft ne peut pas lire a été ignorée @msg graphs come in as plain art, without their data or settings les graphes sont importés comme illustration ordinaire, sans leurs données ni leurs réglages @msg an image whose data is cut short was left out une image aux données tronquées a été ignorée +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers une image incorporée de {_1} × {_2} pixels a été réduite à {_3} × {_4} pixels pour conserver les calques @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel les images liées affichent l’aperçu basse résolution enregistré dans le fichier tant que leurs fichiers ne sont pas trouvés : liez-les à nouveau dans le panneau Liens @msg linked type areas past VectorCraft's limits were left out les zones de texte liées au-delà des limites de VectorCraft ont été omises @msg type that changes writing direction inside a text object opens in the direction of its first character le texte qui change de sens d’écriture dans un objet texte s’ouvre dans le sens de son premier caractère @@ -4784,6 +4858,7 @@ axis Both Les deux @msg object ids are non-negative integers, not {v} les identifiants d’objet sont des entiers positifs ou nuls, pas {v} @msg {key} must be an array of non-negative integer object ids {key} doit être un tableau d’identifiants d’objet, entiers positifs ou nuls @msg invalid object id {value}: expected a non-negative integer identifiant d’objet {value} non valide (un entier positif ou nul est attendu) +@msg `{key}` entry {index} is {value}, not a non-negative integer object id l’entrée {index} de `{key}` vaut {value}, pas un identifiant d’objet (entier positif ou nul) @msg no such object id {value} aucun objet ne porte l’identifiant {value} @msg id {value} is a layer, not an object l’identifiant {value} désigne un calque, pas un objet @msg id {value} is a layer, not an object; move layers with layer.move l’identifiant {value} désigne un calque, pas un objet ; déplacez les calques avec layer.move @@ -4810,12 +4885,19 @@ axis Both Les deux @msg the Pixar file's image data is damaged les données d'image du fichier Pixar sont endommagées @msg the JPEG 2000 file can't be read le fichier JPEG 2000 ne peut pas être lu @msg the JPEG 2000 file has an unsupported color space or JPX content le fichier JPEG 2000 contient un espace colorimétrique ou un contenu JPX non pris en charge +@msg the HEIC or HEIF file can't be read le fichier HEIC ou HEIF ne peut pas être lu +@msg the HEIC or HEIF file holds a kind of image that can't be read le fichier HEIC ou HEIF contient un type d'image qui ne peut pas être lu +@msg HEIC and HEIF images can't be opened in this build les images HEIC et HEIF ne peuvent pas être ouvertes dans cette version @msg the PSD image is too large to read ({_1} × {_2} pixels) l'image PSD est trop grande pour être lue ({_1} × {_2} pixels) @msg zipped 32-bit PSD image data with prediction can't be read les données d'image PSD 32 bits compressées en ZIP avec prédiction ne peuvent pas être lues @msg {depth}-bit PSD files in this colour mode can't be read les fichiers PSD {depth} bits dans ce mode colorimétrique ne peuvent pas être lus @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 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 De-Interlace… Désentrelacement… De-Interlace Désentrelacement @@ -4872,6 +4954,9 @@ axis Both Les deux effect Soft Doux Edge Bord System Title Bar Barre de titre du système + VectorCraft Title Bar on Every Desktop Barre de titre de VectorCraft sur tous les bureaux + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Tant qu’elle est définie, la variable d’environnement VECTORCRAFT_TITLEBAR choisit la barre de titre. + System Title Bar takes precedence over this option while it is on. Tant qu’elle est activée, Barre de titre du système a priorité sur cette option. Value Axis Axe des valeurs On Left Side À gauche On Right Side À droite @@ -4918,7 +5003,7 @@ effect Soft Doux @msg before must name a panel before doit désigner un panneau @msg docking action contains an unknown panel or an excessive split path L’action d’ancrage contient un panneau inconnu ou un chemin de divisions trop long @msg group must be true or false group doit être true ou false -@msg panel is not in a dock group le panneau n’est dans aucun groupe du dock +@msg panel is not in a dock group panel n’est dans aucun groupe du dock @msg only a dock group above or below others collapses, not one beside another seul un groupe du dock situé au-dessus ou au-dessous d’autres se réduit, pas un groupe à côté @msg icon must be true or false icon doit être true ou false @msg icon only docks a panel in the icon column, with no anchor icon ne fait qu’ancrer un panneau dans la colonne d’icônes, sans anchor @@ -5508,6 +5593,11 @@ mark-style Japanese Japonais theme Appearance Apparence Load EPS into Clipboard Charger un EPS dans le presse-papiers @msg an image of {w} × {h} pixels is too large to draw une image de {w} × {h} pixels est trop grande pour être dessinée +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path} : ce n’est pas une image PNG, JPEG, WebP, TIFF ou BMP +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ l’image fait {iw} × {ih} pixels et le plan de travail à cette largeur fait {rw} × {rh} pixels : leurs proportions diffèrent +@msg `threshold` must be a number from 0 to 1 `threshold` doit être un nombre de 0 à 1 +@msg background {v}: a color such as "#808080" arrière-plan {v} : une couleur comme "#808080" +@msg comparing with an image file isn't available in the browser la comparaison avec un fichier image n’est pas disponible dans le navigateur @msg an image of {w} × {h} pixels is shown at reduced size une image de {w} × {h} pixels est affichée en taille réduite @msg the image can't be read as a texture l'image ne peut pas être lue comme texture Fill and Stroke Controls Commandes de fond et de contour @@ -5700,11 +5790,19 @@ theme Appearance Apparence @msg too many library panels were opened in this session trop de panneaux de bibliothèque ont été ouverts dans cette session @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. L’image fait {_1} × {_2} pixels, plus de {LARGE_TRACE_SIDE} sur un côté : elle est vectorisée, mais plus lentement. Un paramètre prédéfini moins fidèle ou une copie plus petite de l’image se vectorise plus vite. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). L’image a été vectorisée à {_1} × {_2} pixels, réduite de {_3} × {_4} ({_5} mégapixels au plus). +@msg byColor must be true or false byColor doit être true ou false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor ne peut pas scinder les groupes du Groupement automatique : vectorisez sans Groupement automatique +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor ne peut pas regrouper un tracé avec un effet Pathfinder actif : développez sans byColor Snapping Magnétisme Snap to Tangent Magnétisme de la tangente Snap to Perpendicular Magnétisme de la perpendiculaire @msg unknown snap option `{key}` (use {options}) option de magnétisme inconnue `{key}` (utilisez {options}) Gap Length: Longueur de lacune : +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Le mode Logo en aplats n’utilise pas keepColors : il vectorise les couleurs indiquées dans logoColors ou celles qu’il trouve. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Rien n’a été vectorisé : aucune des couleurs trouvées par la vectorisation ne correspond à keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Rien n’a été vectorisé : Ignorer la couleur omet toutes les zones des couleurs indiquées dans keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Rien n’a été vectorisé : les couleurs correspondant à keepColors ne produisent aucun tracé. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors : aucune des couleurs trouvées par la vectorisation ne correspond à {c}, donc rien n’en est vectorisé. @msg `colorGroup` needs `library: "document"` `colorGroup` nécessite `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` nécessite `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` nécessite `mode: "color"` @@ -5816,11 +5914,14 @@ axis Y Axis Axe Y @msg Running “{name}”… Exécution de « {name} »… @msg a live effect runs while the document is drawn, so it can't be `heavy` un effet dynamique s’exécute pendant le tracé du document, il ne peut donc pas être `heavy` @msg an exporter always gets an export's budget: leave `budget` out un module externe d’exportation reçoit toujours le budget d’une exportation : omettez `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees « {_1} » demande un budget de calcul important (chaque exécution jusqu’à {minutes} minutes et {times} fois les instructions habituelles) : installez-le avec `allowHeavy: true` une fois que l’utilisateur l’accepte +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants « {_1} » demande un budget de calcul important (chaque exécution jusqu’à {minutes} minutes et {times} fois les instructions habituelles) : une personne doit l’approuver dans l’application de bureau, ou passez `allowHeavy: true` dans une session démarrée avec --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Installer « {name} », qui demande ces autorisations ? ID: {id} ID : {id} Version: {version} Version : {version} + Install “{name}” in place of the plug-in with the same ID? Installer « {name} » à la place du module externe qui a le même ID ? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ce fichier n’est pas le module externe installé auparavant avec cet ID. Il reçoit les réglages et le cache conservés pour cet ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees « {name} » est un autre module que le module externe d’ID {_1} et recevrait les réglages et le cache conservés pour celui-ci : installez-le avec `replace: true` une fois que l’utilisateur l’accepte File SHA-256: {hash} SHA-256 du fichier : {hash} Read the objects it is given Lire les objets qui lui sont transmis Change, add and delete objects in the document Modifier, ajouter et supprimer des objets du document @@ -5828,11 +5929,16 @@ 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 Show its progress, see Cancel and write log lines Afficher sa progression, détecter l’annulation et écrire des lignes de journal -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorisation `progress` nécessite le niveau d’API de module externe {PROGRESS_API} : indiquez `api` {PROGRESS_API} + Keep settings of its own Conserver ses propres réglages + Keep a cache of its own, which you can clear Conserver son propre cache, que vous pouvez vider + Clear Plug-in Cache Vider le cache du module externe +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorisation `{permission}` nécessite le niveau d’API de module externe {PROGRESS_API} : indiquez `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} le module importe `env.{name}` avec un autre type que celui de l’hôte : celui de l’hôte est {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` le module importe `env.{name}`, qu’un module externe ne peut utiliser qu’au niveau d’API de module externe {PROGRESS_API} (`api` {PROGRESS_API}) et avec l’autorisation `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network le module importe `{from}.{name}`, que l’hôte ne propose pas : un module externe ne peut importer que `env.vc_progress`, `env.vc_cancelled` et `env.vc_log` (niveau d’API de module externe 2), et aucun WASI, fichier ni réseau +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` le module importe `env.{name}`, qu’un module externe ne peut utiliser qu’au niveau d’API de module externe {PROGRESS_API} (`api` {PROGRESS_API}) et avec l’autorisation `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network le module importe `{from}.{name}`, que l’hôte ne propose pas : un module externe ne peut importer que les fonctions `env.vc_*` que liste docs/plugins.md (niveau d’API de module externe 2), et aucun WASI, fichier ni réseau @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` may only be true `paintOutput` ne peut valoir que true +@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}` @@ -5840,9 +5946,26 @@ axis Y Axis Axe Y @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` l’autorisation `{shown}` ne s’applique pas à un module externe de type `{_1}` @msg permission `{shown}` is listed twice l’autorisation `{shown}` figure deux fois @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorisation `{shown}` est réservée à une future API de module externe ; cet hôte ne l’accorde pas +@msg only a filter declares `inputs` seul un filtre déclare `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` désigne un type d’objet inconnu `{shown}` : utilisez type, image ou symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` mentionne `{shown}` deux fois +@msg `inputs` needs `api` 2 or later `inputs` nécessite `api` 2 ou plus +@msg `inputs` needs the permission `doc.read` `inputs` nécessite l’autorisation `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) budget mémoire (l’objet {_1} nécessite {size} octets d’entrée, il en reste {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) budget d’objets (plus de {MAX_OBJECTS} objets en lecture seule) @msg unknown permission `{shown}` autorisation inconnue `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees « {name} » demande les autorisations {asked} : installez-le avec `grant` qui les liste une fois que l’utilisateur l’accepte +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants « {name} » demande les autorisations {asked} : une personne doit les approuver dans l’application de bureau, ou passez `grant` dans une session démarrée avec --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` et `allowHeavy` ne prennent effet que dans une session démarrée avec --allow-plugin-grants : approuvez le module externe dans l’application de bureau +@msg Only the person at the keyboard can approve installing a plug-in Seule la personne au clavier peut approuver l’installation d’un module externe @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed « {name} » n’a pas reçu `{p}`, dont sa sortie a besoin : rien n’a été modifié +@msg a heavy filter runs in the background, so it can't declare `commands` un filtre lourd s’exécute en arrière-plan, il ne peut donc pas déclarer `commands` +@msg at most {MAX_COMMANDS} `commands` au plus {MAX_COMMANDS} `commands` +@msg command {name} is declared twice la commande {name} est déclarée deux fois +@msg command {name}: `label` must be 1-64 printable characters commande {name} : `label` doit compter 1-64 caractères imprimables +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter commande {name} : utilisez un id de 1-{MAX_COMMAND_ID} caractères parmi a-z 0-9 _ commençant par une lettre +@msg command {name}: {e} commande {name} : {e} +@msg only a filter or a live effect declares `commands` seul un filtre ou un effet dynamique déclare `commands` +@msg “{name}” › {label}: {e} « {name} » › {label} : {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed le module n’a pas le SHA-256 indiqué par `hash` : rien n’a été installé # Actions panel: action sets (#3712). New Set… Nouveau jeu… @@ -6260,6 +6383,8 @@ breakText Glyphs Glyphes File + Data Set Name Fichier + nom du jeu de données Data Set Name Nom du jeu de données @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` doit être `fileNumber`, `fileDataSetName` ou `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Chaque jeu de données est enregistré sur le même fichier, qui ne conserve que le dernier jeu de données. +@msg save the document with Save As before Save and Close enregistrez le document avec Enregistrer sous avant Enregistrer et fermer @msg no free data set file name in destination folder Aucun nom de fichier libre pour le jeu de données dans le dossier de destination # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6379,9 +6504,20 @@ breakText Glyphs Glyphes Reset Symbol Instance Réinitialiser l'instance de symbole @msg type must be static or dynamic, not {v} type doit être static ou dynamic, et non {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` est un symbole statique : définissez d'abord son type de symbole sur Dynamique +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Seules les couleurs unies du fond et du contour d'un tracé, d'un tracé transparent ou d'un texte sélectionné dans une instance de symbole peuvent changer @msg path must be a list of child indices path doit être une liste d'indices d'enfants @msg no object at path {shown} in the symbol's art aucun objet au chemin {shown} dans l'illustration du symbole @msg give fill, stroke, opacity and/or blend indiquez fill, stroke, opacity et/ou blend +@msg {key} cannot be set on a guide {key} ne peut pas être défini sur un repère +@msg {key} cannot be set on a blend {key} ne peut pas être défini sur un dégradé de formes +@msg {key} cannot be set on an envelope {key} ne peut pas être défini sur une enveloppe +@msg {key} cannot be set on a gradient mesh {key} ne peut pas être défini sur un filet de dégradé +@msg {key} cannot be set on a Repeat {key} ne peut pas être défini sur une répétition +@msg {key} cannot be set on a placed document {key} ne peut pas être défini sur un document importé +@msg {key} cannot be set on an Intertwine group {key} ne peut pas être défini sur un groupe entrelacé +@msg stroke cannot be set on a symbol instance stroke ne peut pas être défini sur une instance de symbole +@msg fill on a symbol instance must be a solid color or none le fill d’une instance de symbole doit être une couleur unie ou none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} ne peut pas être défini sur une image sans l’effet Convertir en forme ou Vectoriser l’objet @msg pass newName, registration, exportType, nineSlice and/or type indiquez newName, registration, exportType, nineSlice et/ou type Text Flow Flux du texte By Rows Par lignes @@ -6411,6 +6547,7 @@ breakText Glyphs Glyphes Create Swatch Legend Créer une légende des nuances Create Swatch Legend… Créer une légende des nuances… HEX HEX + Help Bar Barre d’aide # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Outils de texte Text Tweaker… Ajusteur de texte… @@ -6476,3 +6613,44 @@ 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 plug-in of kind `{_2}`, not an importer `{_1}` est un module externe de type `{_2}`, pas un module externe d’importation +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` est un module externe de type `{_2}`, pas un module externe d’exportation +@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` + +# Safe Mode + Safe Mode… Mode sans échec… + Safe Mode Mode sans échec + Help › Safe Mode… Aide › Mode sans échec… + Enable on Re-launch Activer au prochain lancement + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Le dernier lancement de VectorCraft s’est arrêté pendant le chargement du module externe {name}. Ce module n’en est peut-être pas la cause. + Disable and Continue Désactiver et continuer + Start Normally Démarrer normalement + Start in Safe Mode Démarrer en mode sans échec + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft est en mode sans échec : aucun module externe n’est chargé ni exécuté avant un démarrage normal. + Disabled plug-ins Modules externes désactivés + No plug-ins are disabled. Aucun module externe n’est désactivé. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Le mode sans échec est activé : les modules externes ne sont ni chargés ni exécutés avant que VectorCraft démarre normalement +@msg give `id` or `file` (see plugin.safeMode) indiquez `id` ou `file` (voir plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) ce module externe n’est pas désactivé (voir plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch Impossible d’enregistrer les modules externes désactivés pour le prochain lancement +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Décomposer l’aspect : un effet de module externe de la sélection ne peut pas être décomposé en mode sans échec + +# Align to Pixel Grid + Align to Pixel Grid Aligner sur la grille de pixels +@msg only paths and compound paths can be aligned to the pixel grid seuls les tracés et les tracés transparents peuvent être alignés sur la grille de pixels +@msg side is left or right, not `{s}` side vaut left ou right, pas `{s}` diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 0f212f4fd..fb15934f1 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -343,6 +343,10 @@ Anti-Alias एंटी-एलियास Anti-alias Rasters रास्टर एंटी-एलियास करें Anti-aliased Artwork एंटी-एलियास किया आर्टवर्क + Anti-aliasing Method एंटी-एलियासिंग विधि +antiAlias Sharp शार्प + Crisp क्रिस्प + Strong स्ट्रॉन्ग Anti-aliasing: एंटी-एलियासिंग: Any except extracting pages पेज निकालने के अलावा सभी Any Kind किसी भी प्रकार का @@ -863,6 +867,11 @@ Color Table (4 colors) रंग तालिका (4 रंग) Color Theme रंग थीम Color Themes रंग थीम्स + Color Wheel कलर व्हील + Hue ring ह्यू रिंग + Saturation and brightness सैचुरेशन और ब्राइटनेस + Color field कलर फ़ील्ड + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: रंग का प्रकार: Color wheel कलर व्हील Color: रंग: @@ -1375,7 +1384,7 @@ Drag to float as a toolbar एक टूलबार के रूप में तैरने के लिए खींचें Drag to float the toolbar टूलबार को तैरने के लिए खींचें Drag to move आगे बढ़ने के लिए - Drag to the window's left edge to dock खिड़की के बाएं किनारे पर डॉक करने के लिए खींचें + Drag to the window's left or right edge to dock खिड़की के बाएं या दाएं किनारे पर डॉक करने के लिए खींचें Drag values or labels. Double-click a value to type. मूल्यों या लेबल खींचें। टाइप करने के लिए एक मान को डबल क्लिक करें। Draw ड्रा Draw Behind पीछे खींचना @@ -1629,6 +1638,14 @@ Export As… इस रूप में एक्सपोर्ट करें… Export Asset संपत्ति निर्यात करें Export Assets निर्यात परिसंपत्तियां + Export 3D Object 3D ऑब्जेक्ट निर्यात करें + 3D Export Format 3D निर्यात फ़ॉर्मेट + Export 3D Assets 3D परिसंपत्तियां निर्यात करें +@msg {what} can't be exported in 3D: {e} {what} को 3D में निर्यात नहीं किया जा सकता: {e} +@msg asset {id} isn't a 3D asset परिसंपत्ति {id} 3D परिसंपत्ति नहीं है +@msg asset {id} is a 3D asset: assets.export3D writes it परिसंपत्ति {id} एक 3D परिसंपत्ति है: इसे assets.export3D लिखता है +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) अभी तक कोई 3D परिसंपत्ति नहीं: पहले 3D प्रभाव वाला ऑब्जेक्ट इकट्ठा करें (assets.add3D) +@msg select an object with a 3D effect (or give its ids) 3D प्रभाव वाला ऑब्जेक्ट चुनें (या उसकी आईडी दें) Export CSS CSS निर्यात करें Export CSS… CSS निर्यात करें… Export Data Sets… डेटा सेट निर्यात करें… @@ -1676,6 +1693,17 @@ Extension Lines एक्सटेंशन लाइन Extension: विस्तार: Extrude & Bevel… एक्सट्रूडेड और बेवेल… + 3D (Classic) 3D (क्लासिक) + Extrude & Bevel (Classic)… एक्सट्रूडेड और बेवेल (क्लासिक)… + Surface सतह + Wireframe वायरफ़्रेम + No Shading कोई शेडिंग नहीं + Plastic प्लास्टिक + Highlight Intensity हाइलाइट तीव्रता + Highlight Size हाइलाइट आकार + Blend Steps ब्लेंड चरण + Shading Color शेडिंग रंग + Draw Hidden Faces छिपे हुए फलक बनाएँ Eyedropper आईड्रॉपर Eyedropper Applies: आईड्रॉपपर लागू होता है: Eyedropper Options आईड्रॉपर ऑप्शन्स @@ -2041,6 +2069,7 @@ Help सहायता Help › Search Commands… मदद › खोज कमांड… HEX HEX + Help Bar मदद बार Hex by Column कॉलम द्वारा हेक्स Hex by Row पंक्ति द्वारा हेक्स Hexagon षट्भुज @@ -3509,6 +3538,7 @@ Rating: रेटिंग: Ratio अनुपात Rays रे + Read Action Set File एक्शन सेट फ़ाइल पढ़ें Read Shortcut Set File शॉर्टकट सेट फ़ाइल पढ़ें Reads or sets environment variables पर्यावरण चर पढ़ता है या सेट करता है Ready तैयार @@ -4658,6 +4688,9 @@ System सिस्टम System Layout सिस्टम लेआउट System Title Bar सिस्टम टाइटल बार + VectorCraft Title Bar on Every Desktop हर डेस्कटॉप पर VectorCraft टाइटल बार + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. जब तक एनवायरनमेंट वेरिएबल VECTORCRAFT_TITLEBAR सेट है, टाइटल बार वही चुनता है। + System Title Bar takes precedence over this option while it is on. जब तक सिस्टम टाइटल बार चालू है, उसे इस विकल्प पर प्राथमिकता मिलती है। Tab Tab Tab Stops टैब स्टॉप्स Table तालिका @@ -4682,6 +4715,15 @@ Tate-chu-yoko ताते-चू-योको Tate-chu-yoko Settings ताते-चू-योको सेटिंग्स Tate-chu-yoko Settings… ताते-चू-योको सेटिंग्स… + Composite Fonts… कंपोज़िट फ़ॉन्ट… + Composite Fonts कंपोज़िट फ़ॉन्ट + Export Composite Fonts कंपोज़िट फ़ॉन्ट एक्सपोर्ट करें + Import Composite Fonts कंपोज़िट फ़ॉन्ट इम्पोर्ट करें + Delete Font फ़ॉन्ट हटाएँ + Kanji कांजी + Kana काना + Numerals अंक + Scale at Center केंद्र से आकार बदलें Technical Drawing तकनीकी ड्राइंग Telugu तेलुगु Temperature तापमान @@ -5212,12 +5254,15 @@ Zoom with Mouse Wheel माउस व्हील के साथ ज़ूम @id file.close बंद करें @id pathfinder.add मिलाएँ -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” अधिक संसाधन माँगता है (हर रन में {minutes} मिनट और सामान्य से {times} गुना निर्देश): उपयोगकर्ता की सहमति मिलने पर इसे `allowHeavy: true` से इंस्टॉल करें +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” अधिक संसाधन माँगता है (हर रन में {minutes} मिनट और सामान्य से {times} गुना निर्देश): इसे डेस्कटॉप ऐप में किसी व्यक्ति को स्वीकृत करना होगा, या --allow-plugin-grants से शुरू किए गए सत्र में `allowHeavy: true` दें @msg "{0}" is not a colour: write it as #rrggbb "{0}" एक रंग नहीं है: इसे #rrggbb के रूप में लिखें # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? "{name}" स्थापित करें, जो ये अनुमतियां मांगता है? ID: {id} ID: {id} Version: {version} संस्करण: {version} + Install “{name}” in place of the plug-in with the same ID? समान ID वाले प्लग-इन की जगह “{name}” इंस्टॉल करें? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. यह फ़ाइल इस ID के साथ पहले इंस्टॉल किया गया प्लग-इन नहीं है। इसे उस ID के लिए रखी गई सेटिंग्स और कैश मिलते हैं। +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” आईडी {_1} वाले प्लग-इन से अलग मॉड्यूल है और उसके लिए रखी गई सेटिंग्स और कैश पा लेगा: उपयोगकर्ता की सहमति मिलने पर इसे `replace: true` से इंस्टॉल करें File SHA-256: {hash} फ़ाइल SHA-256: {hash} Read the objects it is given उसे दिए गए ऑब्जेक्ट्स पढ़ना Change, add and delete objects in the document डॉक्यूमेंट में ऑब्जेक्ट्स बदलना, जोड़ना और हटाना @@ -5225,21 +5270,43 @@ Change how the objects its effect is applied to are drawn जिन ऑब्जेक्ट्स पर उसका इफ़ेक्ट लगा है, उनके बनाए जाने का तरीका बदलना Write the document as a file in its own format डॉक्यूमेंट को अपने फ़ॉर्मेट की फ़ाइल के रूप में लिखना Show its progress, see Cancel and write log lines अपनी प्रगति दिखाना, रद्द करने का अनुरोध देखना और लॉग पंक्तियाँ लिखना -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} अनुमति `progress` के लिए प्लग-इन API स्तर {PROGRESS_API} चाहिए: `api` {PROGRESS_API} दें + Keep settings of its own अपनी सेटिंग्स रखना + Keep a cache of its own, which you can clear अपना कैश रखना, जिसे आप साफ़ कर सकते हैं + Clear Plug-in Cache प्लग-इन कैश साफ़ करें +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} अनुमति `{permission}` के लिए प्लग-इन API स्तर {PROGRESS_API} चाहिए: `api` {PROGRESS_API} दें @msg the module imports `env.{name}` with another type than the host's: it is {signature} मॉड्यूल `env.{name}` को होस्ट से अलग प्रकार के साथ आयात करता है: होस्ट का प्रकार {signature} है -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` मॉड्यूल `env.{name}` आयात करता है, जिसे प्लग-इन केवल प्लग-इन API स्तर {PROGRESS_API} (`api` {PROGRESS_API}) पर और अनुमति `progress` के साथ उपयोग कर सकता है -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network मॉड्यूल `{from}.{name}` आयात करता है, जिसे होस्ट उपलब्ध नहीं कराता: प्लग-इन केवल `env.vc_progress`, `env.vc_cancelled` और `env.vc_log` (प्लग-इन API स्तर 2) आयात कर सकता है, और कोई WASI, फ़ाइल या नेटवर्क नहीं +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` मॉड्यूल `env.{name}` आयात करता है, जिसे प्लग-इन केवल प्लग-इन API स्तर {PROGRESS_API} (`api` {PROGRESS_API}) पर और अनुमति `{permission}` के साथ उपयोग कर सकता है +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network मॉड्यूल `{from}.{name}` आयात करता है, जिसे होस्ट उपलब्ध नहीं कराता: प्लग-इन केवल वे `env.vc_*` फ़ंक्शन आयात कर सकता है जो docs/plugins.md में सूचीबद्ध हैं (प्लग-इन API स्तर 2), और कोई WASI, फ़ाइल या नेटवर्क नहीं @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` {_1} है; यह होस्ट प्लग-इन API स्तर 1 से {API_LEVEL} तक का समर्थन करता है +@msg `paintOutput` may only be true `paintOutput` केवल true हो सकता है +@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}` अनुमति चाहिए @msg at most {MAX_PERMISSIONS} `permissions` अधिकतम {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` अनुमति `{shown}` `{_1}` प्रकार के प्लग-इन पर लागू नहीं होती @msg permission `{shown}` is listed twice अनुमति `{shown}` दो बार सूचीबद्ध है +@msg only a filter declares `inputs` केवल फ़िल्टर `inputs` घोषित करता है +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` में अज्ञात ऑब्जेक्ट प्रकार `{shown}` है: type, image या symbolInstance का उपयोग करें +@msg `inputs` lists `{shown}` twice `inputs` में `{shown}` दो बार सूचीबद्ध है +@msg `inputs` needs `api` 2 or later `inputs` के लिए `api` 2 या बाद का स्तर आवश्यक है +@msg `inputs` needs the permission `doc.read` `inputs` के लिए अनुमति `doc.read` आवश्यक है +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) मेमोरी बजट (ऑब्जेक्ट {_1} को इनपुट के {size} बाइट चाहिए, {left} शेष हैं) +@msg object budget (more than {MAX_OBJECTS} read-only objects) ऑब्जेक्ट बजट ({MAX_OBJECTS} से अधिक केवल-पठन ऑब्जेक्ट) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it अनुमति `{shown}` बाद के प्लग-इन API के लिए आरक्षित है; यह होस्ट इसे नहीं देता @msg unknown permission `{shown}` अज्ञात अनुमति `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” अनुमतियां {asked} मांगता है: उपयोगकर्ता की सहमति मिलने पर इसे उन्हें सूचीबद्ध करने वाले `grant` के साथ इंस्टॉल करें +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” अनुमतियां {asked} मांगता है: इन्हें डेस्कटॉप ऐप में किसी व्यक्ति को स्वीकृत करना होगा, या --allow-plugin-grants से शुरू किए गए सत्र में `grant` दें +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` और `allowHeavy` केवल --allow-plugin-grants से शुरू किए गए सत्र में प्रभावी होते हैं: डेस्कटॉप ऐप में प्लग-इन स्वीकृत करें +@msg Only the person at the keyboard can approve installing a plug-in प्लग-इन इंस्टॉल करने की स्वीकृति केवल कीबोर्ड पर मौजूद व्यक्ति दे सकता है @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” को `{p}` नहीं दी गई, जो उसके आउटपुट को चाहिए: कुछ नहीं बदला +@msg a heavy filter runs in the background, so it can't declare `commands` भारी फ़िल्टर पृष्ठभूमि में चलता है, इसलिए वह `commands` घोषित नहीं कर सकता +@msg at most {MAX_COMMANDS} `commands` अधिकतम {MAX_COMMANDS} `commands` +@msg command {name} is declared twice कमांड {name} दो बार घोषित है +@msg command {name}: `label` must be 1-64 printable characters कमांड {name}: `label` में 1-64 प्रिंट करने योग्य वर्ण होने चाहिए +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter कमांड {name}: a-z 0-9 _ के 1-{MAX_COMMAND_ID} वर्णों वाली ऐसी id इस्तेमाल करें जो अक्षर से शुरू हो +@msg command {name}: {e} कमांड {name}: {e} +@msg only a filter or a live effect declares `commands` केवल फ़िल्टर या लाइव इफ़ेक्ट ही `commands` घोषित करता है +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed मॉड्यूल का SHA-256 वह नहीं है जो `hash` बताता है: कुछ भी इंस्टॉल नहीं हुआ @msg “{name}” is not a file "{name}" एक फ़ाइल नहीं है @msg ({note}) ({more}) ({note}) ({more}) @@ -5311,7 +5378,7 @@ @msg {k} must be a number or "auto" {k} एक नंबर या "auto" होना चाहिए। @msg {k} must be a string {k} एक स्ट्रिंग होना चाहिए @msg {k} must be text {k} को टेक्स्ट होना चाहिए -@msg {k} must be true or false {k} को सच या झूठा होना चाहिए +@msg {k} must be true or false {k} को true या false होना चाहिए @msg {k}: can't read `{s}` as a length {k}: `{s}` को लंबाई के रूप में नहीं पढ़ा जा सकता @msg {key} must be [x, y] with finite coordinates {key} को [x, y] होना चाहिए, जिसमें परिमित निर्देशांक होंगे। @msg {key} must be [x,y] {key} होना चाहिए [x,y]] @@ -5376,7 +5443,7 @@ @msg `{_1}` can't be edited or removed `{_1}` को संपादित या हटाया नहीं जा सकता है @msg `{_1}` has no brushes to apply `{_1}` को लागू करने के लिए कोई ब्रश नहीं है @msg `{_1}` has no symbols to place `{_1}` के पास कोई सिंबल नहीं है -@msg `{_1}` is a {_2}, not an exporter `{_1}` एक {_2} है, एक निर्यातक नहीं है +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` `{_2}` प्रकार का प्लग-इन है, निर्यातक नहीं @msg `{_1}` is a live effect: apply it with effect.apply {{"effect"}"}} `{_1}` एक लाइव इफ़ेक्ट है: इसे effect.apply {{"effect"}"}} के साथ लागू करें @msg `{_1}` is an exporter: export with document.export {{"format"}"}} `{_1}` एक एक्सपोर्टर है: document.export {{"format"}"}} से एक्सपोर्ट करें @msg `{_1}` is built in and always listed `{_1}` बनाया गया है और हमेशा सूचीबद्ध है @@ -5398,7 +5465,7 @@ @msg `{id}` isn't a loaded library `{id}` एक लोड लाइब्रेरी नहीं है @msg `{k}.family` is required `{k}.family` की आवश्यकता है @msg `{k}` is a number 0..1 `{k}` एक नंबर 0..1 है। -@msg `{k}` is true or false `{k}` सही या झूठ है +@msg `{k}` is true or false `{k}` true या false है @msg `{k}` isn't an export setting: {_1} `{k}` एक निर्यात सेटिंग नहीं है: {_1} @msg `{k}` must be [x, y] `{k}` होना चाहिए [x, y] @msg `{k}` must be {what} `{k}` होना चाहिए {what} @@ -5442,7 +5509,7 @@ @msg `{key}` must be from {lo} to {hi} `{key}` को {lo} से {hi} होना चाहिए। @msg `{key}` must be from 0 to 180 degrees, not {a} `{key}` 0 से 180 डिग्री तक होना चाहिए, नहीं {a} @msg `{key}` must be one of {_1} `{key}` को {_1} में से एक होना चाहिए -@msg `{key}` must be true or false `{key}` को सच या झूठ होना चाहिए +@msg `{key}` must be true or false `{key}` को true या false होना चाहिए @msg `{key}`: not a colour: {v} `{key}`: कोई रंग नहीं: {v} @msg `{l}` isn't a global or spot colour `{l}` एक वैश्विक या स्पॉट रंग नहीं है @msg `{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText `{m}` एक बिटमैप नहीं है: clipboard.importSvg, importPdf या importText का उपयोग करें @@ -5473,6 +5540,7 @@ @msg `{name}` is a built-in workspace; choose another name `{name}` एक अंतर्निहित वर्कस्पेस है; एक और नाम चुनें @msg `{name}` is a process colour: tints apply to global and spot colours `{name}` एक प्रक्रिया रंग है: टिंट वैश्विक और स्पॉट रंगों पर लागू होते हैं @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` एक स्थिर सिंबल है: अपने सिंबल प्रकार को पहले गतिशील करने के लिए सेट करें +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change सिंबल इंस्टेंस में चुने गए पाथ, कंपाउंड पाथ या टाइप का केवल ठोस रंग वाला फ़िल और स्ट्रोक बदला जा सकता है @msg `{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name) `{name}` `{base}` का एक टिंट है: `{base}` संपादित करें (केवल एक टिंट swatch केवल एक नया नाम लेता है) @msg `{name}` is a working space: choose another with edit.colorSettings first `{name}` वर्किंग स्पेस है: पहले edit.colorSettings से दूसरा चुनें @msg `{name}` is built in and stays `{name}` बनाया गया है और रहता है @@ -5502,6 +5570,7 @@ @msg `anchors` must be an array of [subpath, anchor] pairs `anchors` को [subpath, एंकर] जोड़े की एक सारणी होना चाहिए @msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` में एंकर इंडेक्स (0, 1, 2…) की सूची होनी चाहिए, {k} नहीं @msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` में एंकर इंडेक्स (0, 1, 2…) की सूची होनी चाहिए, {v} नहीं +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` होना चाहिए "none", "sharp", "crisp" या "strong" @msg `artboards` must be a list of artboard indices `artboards` को आर्टबोर्ड इंडेक्स की सूची होना चाहिए @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all या 1, 3-5 जैसी सीमा दें, {v} नहीं @msg `aspect` must be a number (%) `aspect` को संख्या (%) होना चाहिए @@ -5549,18 +5618,18 @@ @msg `diameter` must be from 0.1 to 2500 pt `diameter` को 0.1 से 2500 pt होना चाहिए @msg `diameter` must be from 0.25 to 200 pt `diameter` को 0.25 से 200 pt होना चाहिए @msg `digits` must be one of {_1} `digits` को {_1} में से एक होना चाहिए -@msg `dimImages` is a percentage or false `dimImages` एक प्रतिशत या झूठी है +@msg `dimImages` is a percentage or false `dimImages` एक प्रतिशत या false है @msg `direction` must be "auto", "leftToRight" or "rightToLeft" `direction` को "auto", "leftToRight" या "rightToLeft" होना चाहिए @msg `dither` applies to gradient fills (paint.setFill) `dither` gradient fills (paint.setFill) पर लागू होता है -@msg `dither` applies to gradient fills (stroke: false) `dither` gradient fills (स्ट्रोक: झूठी) पर लागू होता है -@msg `dither` must be true or false `dither` को सच या झूठा होना चाहिए +@msg `dither` applies to gradient fills (stroke: false) `dither` ग्रेडिएंट फ़िल (stroke: false) पर लागू होता है +@msg `dither` must be true or false `dither` को true या false होना चाहिए @msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` {size} bytes (limit {MAX_DOCUMENT_DATA}) है। @msg `each` is the value one design stands for, more than 0 `each` एक डिज़ाइन का मान है, जो 0 से अधिक होना चाहिए @msg `end` must be "start" or "end" `end` को "start" या "end" होना चाहिए। @msg `ends` applies to joining exactly two paths `ends` वास्तव में दो पाथ में शामिल होने के लिए लागू होता है @msg `event` must be one of {_1} `event` is one of the {_1} @msg `every` must be "all", "odd" or "even", not {v} `every` होना चाहिए "all", "odd" या "even", नहीं {v} -@msg `expand` must be true or false `expand` को सच या झूठ होना चाहिए +@msg `expand` must be true or false `expand` को true या false होना चाहिए @msg `exportPreset` is the name of a preset of document.exportPresets.list `exportPreset`, document.exportPresets.list के किसी प्रीसेट का नाम है @msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` में पिछली text.findFontFiles सर्च में मिली फ़ॉन्ट फ़ाइलों के 1 से 1000 पाथ होने चाहिए @msg `folder` must be an absolute folder path `folder` एक पूर्ण फ़ोल्डर पाथ होना चाहिए @@ -5618,11 +5687,11 @@ @msg `kinsoku` must be "none", "hard" or "soft" `kinsoku` "none", "hard" या "soft" होना चाहिए। @msg `label` must be a non-empty name of at most 200 characters `label` को 200 वर्णों में से एक गैर-खाली नाम होना चाहिए @msg `language` must be a language name or tag, such as "Turkish" or "tr" `language` एक भाषा का नाम या टैग होना चाहिए, जैसे "Turkish" या "tr" -@msg `leading` must be from 0.1 to 5000, or null for Auto `leading` को 0.1 से 5000 तक होना चाहिए, या ऑटो के लिए शून्य होना चाहिए +@msg `leading` must be from 0.1 to 5000, or null for Auto `leading` को 0.1 से 5000 तक होना चाहिए, या ऑटो के लिए null होना चाहिए @msg `leadingModel` must be "romanBaseline" or "emBoxTop" `leadingModel` को "romanBaseline" या "emBoxTop" होना चाहिए @msg `length` must be a number of at most 1000000 pt either way `length` को 1000000 pt में से एक होना चाहिए। @msg `library` is a library id or name (see {list}) `library` एक लाइब्रेरी आईडी या नाम है ( {list} देखें) -@msg `line` must be true or false `line` को सही या झूठा होना चाहिए +@msg `line` must be true or false `line` को true या false होना चाहिए @msg `method` must be classic or perceptual `method` क्लासिक या अवधारणात्मक होना चाहिए @msg `min` is greater than `max` `min` अधिकतम `max` से अधिक है @msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` और `maxAnchors` पूरे नंबर होना चाहिए @@ -5660,7 +5729,7 @@ @msg `ratio` must be from 0.1 to 0.5 `ratio` को 0.1 से 0.5 तक होना चाहिए @msg `rect` must be [x, y, width, height] with a positive size `rect` होना चाहिए [x, y, चौड़ाई, ऊंचाई] एक सकारात्मक आकार के साथ @msg `relation` must be overlap, inside or outside `relation` को ओवरलैप, अंदर या बाहर होना चाहिए -@msg `reversed` must be true or false `reversed` को सच या झूठ होना चाहिए +@msg `reversed` must be true or false `reversed` को true या false होना चाहिए @msg `rotate` must be a finite number `rotate` एक परिमित संख्या होना चाहिए @msg `rule` must be nonZero or evenOdd `rule` होना चाहिए nonZero or evenOdd होना चाहिए @msg `sampleSize` must be 1, 3 or 5 `sampleSize` होना चाहिए 1, 3 या 5 @@ -5672,7 +5741,7 @@ @msg `seriesIndexes` is a non-empty list of series indexes `seriesIndexes` श्रृंखला सूचकांकों की एक गैर-खाली सूची है @msg `seriesIndexes` needs a `type` or a `valueAxis` `seriesIndexes` को `type` या `valueAxis` की आवश्यकता होती है @msg `shift` must be a number of points `shift` को कई अंक होना चाहिए -@msg `showCenter` must be true or false `showCenter` को सच या झूठ होना चाहिए +@msg `showCenter` must be true or false `showCenter` को true या false होना चाहिए @msg `singleWordJustification` must be "fullJustify", "alignLeft", "alignCenter" or "alignRight" `singleWordJustification` को "fullJustify", "alignLeft", "alignCenter" या "alignRight" होना चाहिए। @msg `size` is a percentage of the base text's size, 1 to 100 `size` आधार टेक्स्ट के आकार का एक प्रतिशत है, 1 से 100 @msg `source` and `target` are the same object `source` और `target` एक ही ऑब्जेक्ट है @@ -5686,7 +5755,7 @@ @msg `stops` must be an array `stops` एक सारणी होना चाहिए @msg `story` styles the whole story: give no `start` or `end` `story` पूरी स्टोरी को स्टाइल करता है: `start` या `end` न दें @msg `story` takes the whole story: give no `start` or `end` `story` पूरी स्टोरी लेता है: `start` या `end` न दें -@msg `strokeMode` applies to strokes (stroke: true) `strokeMode` स्ट्रोक पर लागू होता है (स्ट्रोक: सच) +@msg `strokeMode` applies to strokes (stroke: true) `strokeMode` स्ट्रोक पर लागू होता है (stroke: true) @msg `style` must be roman or japanese `style` को रोमन या जापानी होना चाहिए @msg `subpaths` must be two integer indices `subpaths` होना चाहिए दो पूर्णांक सूचकांक @msg `svg` must be an object of SVG options (see document.formats) `svg` को SVG ऑप्शन्स का ऑब्जेक्ट होना चाहिए (document.formats देखें) @@ -5707,8 +5776,8 @@ @msg `to` takes the rows of one object's stack, not aligned rows `to` एक ऑब्जेक्ट के स्टैक की पंक्तियों को लेता है, पंक्तियां संरेखित नहीं है @msg `tolerance` must be from 0 to 3 pt `tolerance` को 0 से 3 pt के बीच होना चाहिए @msg `tolerance` must be from 1 to 100 `tolerance` को 1 से 100 के बीच होना चाहिए -@msg `top` and `below` can't both be true `top` और `below` दोनों सच नहीं हो सकते -@msg `useLab` must be true or false `useLab` को सही या झूठ होना चाहिए +@msg `top` and `below` can't both be true `top` और `below` दोनों true नहीं हो सकते +@msg `useLab` must be true or false `useLab` को true या false होना चाहिए @msg `vAlign` must be default, top, middle, baseline or bottom `vAlign` डिफ़ॉल्ट, शीर्ष, मध्य, बेसलाइन या नीचे होना चाहिए @msg `values` must be a list `values` एक सूची होना चाहिए @msg `values` must be an object `values` एक ऑब्जेक्ट होना चाहिए @@ -5743,7 +5812,7 @@ @msg a document links at most {MAX_SVG_SCRIPTS} JavaScript files अधिकांश {MAX_SVG_SCRIPTS} जावास्क्रिप्ट फ़ाइलों पर एक डॉक्यूमेंट लिंक @msg a document needs at least one artboard कम से कम एक आर्टबोर्ड की आवश्यकता होती है @msg a document needs at least one layer डॉक्यूमेंट की कम से कम एक लेयर की आवश्यकता होती है -@msg a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard एक DXF ड्राइंग एक मूल है: पास useArtboards: कलाबोर्ड प्रति एक ड्राइंग के लिए सच +@msg a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard एक DXF ड्राइंग का एक मूल होता है: हर आर्टबोर्ड के लिए एक ड्राइंग के लिए useArtboards: true दें @msg a feConvolveMatrix filter's order is {x}×{y}, which overflows the kernel-size multiply usvg makes एक feConvolveMatrix फिल्टर का आदेश {x} ×{y} है, जो कर्नेल-आकार की बहुप्लाई usvg को ओवरफ्लो करता है। @msg a freeform gradient keeps at least one point एक freeform ग्रेडिएंट कम से कम एक बिंदु रखता है @msg a gradient needs at least two stops कम से कम दो स्टॉप की आवश्यकता होती है @@ -5766,7 +5835,7 @@ @msg a mask that covers several layers comes in as a clipping group in each layer it covers एक मास्क जो कई लेयर्स को कवर करता है, प्रत्येक लेयर में एक क्लिपिंग समूह के रूप में आता है, यह कवर करता है @msg a multi-layer mask needs one clipping path बहुलेयर मास्क को एक क्लिपिंग पाथ की आवश्यकता होती है @msg a multi-layer mask's operators are out of order बहुलेयर मास्क ऑपरेटर क्रम से बाहर हैं -@msg a name names one asset: give multiple: false, or one object नाम एक परिसंपत्ति: एकाधिक देना: झूठा, या एक ऑब्जेक्ट +@msg a name names one asset: give multiple: false, or one object एक नाम एक ही एसेट को दर्शाता है: multiple: false दें, या एक ऑब्जेक्ट @msg a new document refuses this file because of these variables: {names} एक नया डॉक्यूमेंट इन चरों के कारण इस फ़ाइल को मना कर देता है: {names} @msg a new document won't read these variables back with their kind: {names} नया डॉक्यूमेंट इन वेरिएबल्स का प्रकार वापस नहीं पढ़ पाएगा: {names} @msg a page can't be read एक पेज पढ़ा नहीं जा सकता @@ -5815,7 +5884,7 @@ @msg a symbol can't hold an instance of itself एक सिंबल खुद का एक उदाहरण नहीं पकड़ सकता है @msg a symbol named `{n}` already exists पहले से ही `{n}` नाम का सिंबल मौजूद है @msg a theme has 1 to {THEME_SIZE} colours थीम में 1 से {THEME_SIZE} रंग होते हैं -@msg a theme is already called {name} (replace: "{name}" overwrites it) एक विषय को पहले से ही {name} कहा जाता है (प्रतिस्थापन: "{name}" इसे ओवरराइट करता है) +@msg a theme is already called {name} (replace: "{name}" overwrites it) {name} नाम की थीम पहले से है (replace: "{name}" उसे ओवरराइट करता है) @msg a toolbar name has at most {MAX_NAME} characters टूलबार नाम में सबसे {MAX_NAME} वर्ण हैं @msg a value has the wrong type for the operator एक मूल्य ऑपरेटर के लिए गलत प्रकार है @msg a value is outside the range the operator takes एक मूल्य सीमा के बाहर है जो ऑपरेटर लेता है @@ -5825,7 +5894,7 @@ @msg a view named `{name}` exists `{name}` नामक दृश्य मौजूद है @msg a workspace keeps at most {MAX} toolbars एक वर्कस्पेस सबसे {MAX} टूलबार में रहता है @msg a wraparound group is inside a compound path एक रैपराउंड समूह एक मिश्रित पाथ के अंदर है -@msg action is required कार्रवाई की आवश्यकता है +@msg action is required action आवश्यक है @msg add a format to export निर्यात करने के लिए एक फ़ॉर्मैट जोड़ें @msg Add Points at Crossings: a path went missing क्रॉसिंग्स में अंक जोड़ें: एक पाथ गायब हो गया @msg aiVersion {v}: "pdf" (PDF-compatible, the default) or "7" (legacy .ai, version 7) aiVersion {v}: "pdf" (PDF-संगत, डिफ़ॉल्ट) या "7" (पुरानी .ai, संस्करण 7) @@ -6018,12 +6087,12 @@ @msg Choose two non-parallel straight segments दो गैर-parallel सीधे खंड चुनें @msg Choose two nonzero angle arms दो nonzero कोण हथियार चुनें @msg Click in type with a Type tool to split it इसे विभाजित करने के लिए टाइप टूल के साथ टाइप करें -@msg clip must be true or false clip: क्लिप को सच या झूठा होना चाहिए +@msg clip must be true or false clip को true या false होना चाहिए @msg clipboard is empty क्लिपबोर्ड खाली है @msg clippingMask must be true or false clippingMask को true या false होना चाहिए @msg closed must be a boolean closed: बंद होना चाहिए एक boolean @msg closing needs three anchors closing: बंद करने की जरूरत है तीन एंकर -@msg collapsed must be true or false collapsed: पतन सच होना चाहिए या गलत होना चाहिए +@msg collapsed must be true or false collapsed को true या false होना चाहिए @msg color.profile: no RGB or CMYK profile is called “{profile}” Color.profile: कोई RGB या CMYK प्रोफ़ाइल को "{profile}" कहा जाता है। @msg colorMode must be rgb or cmyk, not `{m}` colorMode को rgb या cmyk होना चाहिए, `{m}` नहीं @msg colorModel must be {own} (the document's mode), grayscale or bitmap colorModel को {own} (डॉक्यूमेंट का मोड), grayscale या bitmap होना चाहिए @@ -6036,8 +6105,8 @@ @msg column {column}: `{cell}` names no variable कॉलम {column}: `{cell}` नाम कोई चर नहीं @msg column {column}: the variable name is longer than {MAX_NAME} characters कॉलम {column}: चर नाम {MAX_NAME} अक्षरों से लंबा है @msg column docks a set as a dock column, with no anchor or icon कॉलम एक डॉक कॉलम के रूप में सेट करता है, जिसमें कोई एंकर या आइकन नहीं होता है -@msg column must be true or a number from 1 column: कॉलम सही होना चाहिए या 1 से एक नंबर होना चाहिए -@msg column: true floats the whole column and takes no onto, above or below स्तंभ: सच पूरे स्तंभ को तैरता है और ऊपर या नीचे नहीं लेता है +@msg column must be true or a number from 1 column को true या 1 या उससे बड़ी संख्या होना चाहिए +@msg column: true floats the whole column and takes no onto, above or below column: true पूरे कॉलम को फ़्लोट करता है और onto, above या below नहीं लेता @msg columns {first} and {column} both name the variable `{name}` कॉलम {first} और {column} दोनों नाम चर `{name}` @msg Combine Slices: the slices have no bounds स्लाइस को मिलाएं: स्लाइस में कोई सीमा नहीं है @msg command `{0}` is not available right now: {1} आदेश `{0}` अभी उपलब्ध नहीं है: {1} @@ -6113,7 +6182,7 @@ @msg decimals must be {_1}–{_2} (got {_3}) decimals: दशमलव होना चाहिए {_1}-{_2} ({_3}) @msg decoded data is too large डीकोडेड डेटा बहुत बड़ा है @msg define a variable first पहले एक चर को परिभाषित करना -@msg detect must be true or false detect: पता लगाना सही या गलत होना चाहिए +@msg detect must be true or false detect को true या false होना चाहिए @msg Dimension is locked or hidden आयाम बंद या छिपा हुआ है @msg Dimension mode does not match its source आयाम मोड अपने स्रोत से मेल नहीं खाता है @msg Dimension point is outside the canvas आयाम बिंदु कैनवास के बाहर है @@ -6266,6 +6335,16 @@ @msg give dataBase64 dataBase64 दें @msg give either at (a click) or area (a path drawn around the overlaps) या तो (एक क्लिक) या क्षेत्र (ओवरलैप के चारों ओर खींचा गया एक पाथ) देना @msg give fill, stroke, opacity and/or blend भरने, स्ट्रोक, अपारदर्शिता और/या मिश्रण देते हैं +@msg {key} cannot be set on a guide गाइड पर {key} सेट नहीं किया जा सकता +@msg {key} cannot be set on a blend ब्लेंड पर {key} सेट नहीं किया जा सकता +@msg {key} cannot be set on an envelope एन्वेलप पर {key} सेट नहीं किया जा सकता +@msg {key} cannot be set on a gradient mesh ग्रेडिएंट मेश पर {key} सेट नहीं किया जा सकता +@msg {key} cannot be set on a Repeat रिपीट पर {key} सेट नहीं किया जा सकता +@msg {key} cannot be set on a placed document प्लेस्ड डॉक्यूमेंट पर {key} सेट नहीं किया जा सकता +@msg {key} cannot be set on an Intertwine group इंटरट्वाइन समूह पर {key} सेट नहीं किया जा सकता +@msg stroke cannot be set on a symbol instance सिंबल इंस्टेंस पर stroke सेट नहीं किया जा सकता +@msg fill on a symbol instance must be a solid color or none सिंबल इंस्टेंस का fill ठोस रंग या none होना चाहिए +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it शेप में बदलें या रूपरेखा ऑब्जेक्ट इफ़ेक्ट के बिना इमेज पर {key} सेट नहीं किया जा सकता @msg give formats or a preset, not both फ़ॉर्मैट या एक पूर्व निर्धारित देना, दोनों ही नहीं @msg give horizontal or vertical क्षैतिज या ऊर्ध्वाधर देना @msg give newName or delete newName या delete दें @@ -6274,7 +6353,7 @@ @msg give one of assets, layers, selection or fullDocument assets, layers, selection या fullDocument में से एक दें @msg give only one of onto, above, below, icon and a column place केवल ऊपर, नीचे, आइकन और एक स्तंभ स्थान देने के लिए @msg give only one of onto, below and above नीचे और ऊपर केवल एक देना -@msg give origin: [x, y], or reset: true मूल देना: [x, y], या रीसेट करें: सच +@msg give origin: [x, y], or reset: true origin: [x, y] दें, या reset: true @msg give overprintFill, overprintStroke, showCenter, imageMap, url or note overprintFill, overprintStroke, showCenter, imageMap, url या नोट @msg give path (a file) or folder पाथ (एक फ़ाइल) या फ़ोल्डर देना @msg give path, or name and dataBase64 path या name और dataBase64 दें @@ -6295,7 +6374,7 @@ @msg give x, y, or a handle's length or angle x, y, या एक हैंडल की लंबाई या कोण देना @msg Global Edit is off ग्लोबल एडिट बंद है @msg Global Edit works on paths, compound paths, symbol instances and groups of them ग्लोबल एडिट पाथ, मिश्रित पाथ, सिंबल उदाहरणों और उनके समूहों पर काम करता है -@msg global must be true or false global: वैश्विक होना चाहिए या गलत होना चाहिए +@msg global must be true or false global को true या false होना चाहिए @msg glyph {_1} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system) Glyph {_1} चरित्र की स्टाइल (जैसे सभी कैप्स, छोटे कैप्स या एक संख्यात्मक प्रणाली) के साथ {ch} के लिए तैयार नहीं है। @msg glyph {g} is not an alternate of {ch} in {_1} {_2} Glyph {g} {_1} {_2} में {ch} का एक वैकल्पिक नहीं है। @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it ग्रेडिएंट "{name}" को फ़ाइल में परिभाषित नहीं किया गया है: इसके लिए एक सादे रंग खड़ा है @@ -6305,7 +6384,7 @@ @msg gridColors must be a preset name or two colours gridColors को प्रीसेट नाम या दो रंग होना चाहिए @msg gridSize must be small, medium or large gridSize को small, medium या large होना चाहिए @msg group must be the id of a Shaper Group group: समूह को एक शेपर ग्रुप का आईडी होना चाहिए -@msg group must be true or false group: समूह को सच या झूठा होना चाहिए +@msg group must be true or false group को true या false होना चाहिए @msg guide {g} is not selected गाइड {g} नहीं चुना गया है @msg guides are locked गाइड लॉक कर रहे हैं @msg handle must be 0..3 handle को 0..3 होना चाहिए @@ -6317,7 +6396,7 @@ @msg highlight two or more rows to merge दो या अधिक पंक्तियों को मर्ज करने के लिए हाइलाइट करें @msg HTML Text applies to object slices of type only एचटीएमएल टेक्स्ट केवल टाइप के ऑब्जेक्ट स्लाइस पर लागू होता है @msg icon docks a panel in the icon column: use window.panel.dock आइकन कॉलम में एक पैनल डॉक करता है: विंडो का उपयोग करें.पैनल.dock -@msg icon must be true or false icon: आइकन सही या झूठी होना चाहिए +@msg icon must be true or false icon को true या false होना चाहिए @msg icon only docks a panel in the icon column, with no anchor आइकन केवल आइकन कॉलम में एक पैनल को डॉक करता है, जिसमें कोई एंकर नहीं होता है @msg id {value} is a layer, not an object Id {value} एक लेयर है, कोई ऑब्जेक्ट नहीं है @msg id {value} is a layer, not an object; move layers with layer.move Id {value} एक लेयर है, कोई ऑब्जेक्ट नहीं है; लेयर के साथ लेयर्स को स्थानांतरित करें। @@ -6338,10 +6417,10 @@ @msg imageMap `{m}`: none, client or server imageMap `{m}`: none, client या server @msg Imported {what}: {_1} आयात {what}: {_1} @msg Imported library {name} आयातित लाइब्रेरी {name} -@msg included must be true or false included: इसमें सही या गलत होना चाहिए +@msg included must be true or false included को true या false होना चाहिए @msg index must be ≥ 0 index: सूचकांक होना चाहिए ≥ 0 -@msg index must be a number or first, previous, next or last index: सूचकांक एक नंबर या पहले होना चाहिए, पिछले, अगले या आखिरी -@msg index must be a number or first, previous, next or last, not `{other}` index: सूचकांक एक नंबर या पहले होना चाहिए, पिछले, अगले या आखिरी, नहीं `{other}` +@msg index must be a number or first, previous, next or last index को एक नंबर या first, previous, next या last होना चाहिए +@msg index must be a number or first, previous, next or last, not `{other}` index को एक नंबर या first, previous, next या last होना चाहिए, `{other}` नहीं @msg index out of range रेंज से बाहर सूचकांक @msg Installed plug-in {_1} स्थापित प्लग-इन {_1} @msg installing from a path is not available on the web; pass the module as `dataBase64` एक पाथ से स्थापित वेब पर उपलब्ध नहीं है; मॉड्यूल को `dataBase64`` के रूप में पास करें @@ -6366,6 +6445,7 @@ @msg invalid image data for `{0}` `{0}` के लिए अमान्य इमेज डेटा @msg invalid native panel set अमान्य मूल पैनल सेट @msg invalid object id {value}: expected a non-negative integer अमान्य ऑब्जेक्ट id {value}: एक गैर-नकारात्मक पूर्णांक की उम्मीद +@msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` की प्रविष्टि {index} {value} है, गैर-नकारात्मक पूर्णांक ऑब्जेक्ट id नहीं @msg Invalid panel layout; restored the default dock अमान्य पैनल लेआउट; डिफ़ॉल्ट डॉक पुनर्स्थापित किया गया @msg invalid parameter name {k} अमान्य पैरामीटर नाम {k} @msg invalid parameters for `{cmd}`: {msg} `{cmd}` के लिए अमान्य पैरामीटर: {msg} @@ -6433,7 +6513,7 @@ @msg layers can't be intertwined: select the objects in them लेयर्स में हस्तक्षेप नहीं किया जा सकता है: उनमें ऑब्जेक्ट्स सिलेक्ट करें @msg layers can't be wrap objects लेयर्स को लपेटा नहीं जा सकता @msg layers go only at the top level or in layers, objects in layers and groups लेयरें केवल शीर्ष स्तर पर या लेयर्स में, लेयर्स और समूहों में ऑब्जेक्टएं -@msg layers must be true or false layers: लेयरें सत्य या झूठी होनी चाहिए +@msg layers must be true or false layers को true या false होना चाहिए @msg layout must be gridByRow, gridByColumn, row or column layout को gridByRow, gridByColumn, row या column होना चाहिए @msg leading must be a number or "auto" leading: अग्रणी एक नंबर या "auto" होना चाहिए @msg left/right must be non-negative widths बाएं / दाएं गैर-नकारात्मक चौड़ाई होना चाहिए @@ -6443,7 +6523,7 @@ @msg line {line}: more than {MAX_CSV_COLS} columns लाइन {line}: {MAX_CSV_COLS} कॉलम से अधिक @msg line {line}: unclosed quote लाइन {line}: unclosed उद्धरण @msg line {line}: value for `{var}` is longer than {MAX_GRAPH_CSV} bytes लाइन {line}: `{var}` के लिए मूल्य {MAX_GRAPH_CSV} बाइट्स से अधिक है -@msg line {line}: value for `{var}` must be true or false लाइन {line}: `{var}` के लिए मान सही या झूठ होना चाहिए +@msg line {line}: value for `{var}` must be true or false लाइन {line}: `{var}` के लिए मान true या false होना चाहिए @msg line {line}: value for `{var}` must match its variable kind लाइन {line}: `{var}` के लिए मान इसके परिवर्तनीय प्रकार से मेल खाता होना चाहिए @msg lineEndings `{other}`: lf or crlf lineEndings `{other}`: lf या crlf @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel लिंक किए गए चित्रों से पता चलता है कि फ़ाइल में सेव किए जाने वाले निम्न-रिज़ॉल्यूशन प्रीव्यू को उनकी फ़ाइलों को मिलने तक: लिंक पैनल में उन्हें फिर से लिंक करें @@ -6467,7 +6547,7 @@ @msg matte: {e} मैट: {e} @msg Measured anchor no longer exists मापने वाला एंकर अब मौजूद नहीं है @msg Measured segment no longer exists मापदंड अब मौजूद नहीं है -@msg member is for order front, forward, backward or back सदस्य ऑर्डर फ्रंट, फॉरवर्ड, बैकवर्ड या बैक के लिए है +@msg member is for order front, forward, backward or back member केवल order front, forward, backward या back के लिए है @msg memory budget (the selection is too large to hand over) स्मृति बजट (चुनाव बहुत बड़ा है) @msg missing `{k}` लापता `{k}` @msg missing `{key}` लापता `{key}` @@ -6552,7 +6632,7 @@ @msg missing target लापता लक्ष्य @msg missing to याद करना @msg missing x, y लापता xy -@msg mode is set, add or toggle, not `{other}` मोड सेट, ऐड या टॉगल है, नहीं `{other}` +@msg mode is set, add or toggle, not `{other}` mode set, add या toggle है, `{other}` नहीं @msg mode must be "cmyk" or "rgb" mode: मोड "cmyk" या "rgb" होना चाहिए @msg mode must be area|onPath, got {o} mode: मोड होना चाहिए क्षेत्रफल (onPath), {o} मिला @msg module is {_1} bytes (limit {_2}) मॉड्यूल {_1} बाइट्स (सीम {_2}) है। @@ -6576,7 +6656,7 @@ @msg name is empty नाम खाली है @msg name must be a toolbar's name name: नाम एक टूलबार का नाम होना चाहिए @msg name the file फ़ाइल नाम -@msg names is empty नाम खाली है +@msg names is empty names खाली है @msg names must be an array of symbol names names: नाम सिंबल नामों की एक सरणी होना चाहिए @msg need `anchor` or `segment` + `t` की जरूरत `anchor` या `segment` + `t` @msg need at least one anchor कम से कम एक एंकर की जरूरत है @@ -6853,6 +6933,8 @@ @msg not a Word document: its main part is not a WordprocessingML document वर्ड डॉक्यूमेंट नहीं: इसका मुख्य हिस्सा वर्डप्रोसेसिंगएमएल डॉक्यूमेंट नहीं है @msg not a Word document: its package has no main document part नहीं एक वर्ड डॉक्यूमेंट: इसके पैकेज में कोई मुख्य डॉक्यूमेंट हिस्सा नहीं है @msg not a Word document: its WordDocument stream doesn't start with a file information block वर्ड डॉक्यूमेंट नहीं: इसकी WordDocument धारा फ़ाइल सूचना ब्लॉक के साथ शुरू नहीं होती है +@msg the action set file can't be read in full एक्शन सेट फ़ाइल पूरी तरह नहीं पढ़ी जा सकती +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” लोड किया गया: {n} चरण छोड़ा गया|“{name}” लोड किया गया: {n} चरण छोड़े गए @msg not an action set file एक एक्शन सेट फ़ाइल नहीं @msg not an EMF or WMF file नहीं एक EMF या WMF फ़ाइल @msg not an export presets file (format `{_1}`) नहीं निर्यात presets फ़ाइल (format `{_1}`) @@ -6918,15 +7000,15 @@ @msg warped text objects that came in as drawn art, not as editable type: {_1} मोड़े गए टेक्स्ट ऑब्जेक्ट जो संपादन योग्य टेक्स्ट के बजाय कला के रूप में आए: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} उन ऑब्जेक्ट्स को जो फ़ाइल के स्वयं के पृष्ठ से अलग-अलग तरीके से आकर्षित होते हैं, उनकी कला के रूप में आया: {_1} @msg objects were left out ऑब्जेक्ट्स को छोड़ दिया गया था -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet कई भराव या स्ट्रोक, इफ़ेक्ट या ब्रश के साथ ऑब्जेक्टएं उनके ड्रॉइंग लुक ({_1}) के रूप में आईं: उनकी उपस्थिति अभी तक संपादन योग्य नहीं है +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet कई भराव या स्ट्रोक, परिवर्तनीय चौड़ाई वाले स्ट्रोक, इफ़ेक्ट या ब्रश के साथ ऑब्जेक्टएं उनके ड्रॉइंग लुक ({_1}) के रूप में आईं: उनकी उपस्थिति अभी तक संपादन योग्य नहीं है @msg offset out of range सीमा से बाहर ऑफसेट @msg Offset Path: {e} ऑफसेट पाथ: {e} @msg Offset Path: select paths ऑफसेट पाथ: सिलेक्शन पाथ @msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) ऑफसेट पाथ: ऑफ़सेट सिलेक्टेड पाथ की कुछ भी नहीं छोड़ता है (आकार की तुलना में एक निर्धारित बड़ा) @msg Offset Path: the path is too complex to offset ऑफसेट पाथ: पाथ ऑफसेट करने के लिए बहुत जटिल है -@msg on must be true or false on: पर सच होना चाहिए या गलत होना चाहिए +@msg on must be true or false on को true या false होना चाहिए @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}" @@ -6946,7 +7028,7 @@ @msg options must be an object (see file.formatOptions) options: ऑप्शन्स एक ऑब्जेक्ट होना चाहिए (file.formatOptions देखें) @msg order must be leftToRight or rightToLeft order को leftToRight या rightToLeft होना चाहिए @msg orientation must be portrait or landscape, not `{o}` orientation: अभिविन्यास चित्र या परिदृश्य होना चाहिए, नहीं `{o}` -@msg others must be true or false others: दूसरों को सच होना चाहिए या गलत होना चाहिए +@msg others must be true or false others को true या false होना चाहिए @msg Outline Stroke: no stroked paths selected आउटलाइन स्ट्रोक: सिलेक्टेड कोई स्ट्रोक पाथ नहीं @msg outlineText {v}: true or false outlineText {v}: true या false @msg output intent profile: {e} उत्पादन आशय प्रोफ़ाइल: {e} @@ -7017,7 +7099,7 @@ @msg pins must be finite pins: पिन परिमित होना चाहिए @msg place is above, below or inside, not `{other}` place: स्थान ऊपर, नीचे या अंदर है, नहीं `{other}` @msg Placing {_1} पतंग {_1} -@msg plane must be left, right or ground plane: समतल बाएं, दाएं या जमीन होना चाहिए +@msg plane must be left, right or ground plane को left, right या ground होना चाहिए @msg plane must be left|right|ground plane को left|right|ground होना चाहिए @msg plane must be left|right|ground|none plane को left|right|ground|none होना चाहिए @msg plug-in ABI: {0} प्लग-इन ABI: {0} @@ -7068,7 +7150,7 @@ @msg recovery copy `{file}`: {e} वसूली प्रतिलिपि `{file}`: {e} @msg rect and {k} contradict each other: give the area or the artboards सही और {k} एक दूसरे के विपरीत: क्षेत्र या आर्टबोर्ड देना @msg rect applies to raster formats, not {_1} सही रेस्टर फ़ॉर्मैट्स पर लागू होता है, न कि {_1} -@msg rect is [x, y, width, height] in points सही [x, y, चौड़ाई, ऊंचाई] अंक में है +@msg rect is [x, y, width, height] in points rect पॉइंट में [x, y, width, height] है @msg rect must be [x, y, width, height] rect: [x, y, चौड़ाई, ऊंचाई] होना चाहिए @msg rect must be [x, y, width, height] with a positive width and height rect: सही होना चाहिए [x, y, चौड़ाई, ऊंचाई] सकारात्मक चौड़ाई और ऊंचाई के साथ @msg rect needs finite numbers and a positive width and height rect: सही की जरूरत परिमित संख्या और सकारात्मक चौड़ाई और ऊंचाई @@ -7160,6 +7242,13 @@ @msg select area type and closed paths (at least one area type object) क्षेत्र प्रकार और बंद पाथ (कम से कम एक क्षेत्र प्रकार ऑब्जेक्ट) सिलेक्ट करें @msg select area type whose first line can fit its frame क्षेत्र के प्रकार सिलेक्ट करें जिनकी पहली पंक्ति अपने फ्रेम को फिट कर सकती है @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 artwork to add its colours अपने रंगों को जोड़ने के लिए आर्टवर्क सिलेक्ट करें @msg select artwork to make a group of its colours अपने रंगों का एक समूह बनाने के लिए आर्टवर्क सिलेक्ट करें @msg select artwork to recolour, or give a colour `group` रंग बदलने के लिए आर्टवर्क चुनें या रंग `group` दें @@ -7236,7 +7325,7 @@ @msg shortcut for `{k}` must be a string `{k}` के लिए शॉर्टकट एक स्ट्रिंग होना चाहिए @msg show `{show}`: all, missing, modified or embedded `{show}``: सभी, लापता, संशोधित या एम्बेडेड @msg Show All Hidden Attributes: nothing is hidden Show all Hidden Attributes: कुछ भी छिपा नहीं है -@msg show must be true or false show: प्रदर्शन को सच या झूठा होना चाहिए +@msg show must be true or false show को true या false होना चाहिए @msg Simplify Anchors: the paths have too many anchors to simplify at once एंकर को सरलीकृत करें: पाथ में एक बार में सरल बनाने के लिए कई एंकर हैं @msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size: आकार "small", "medium", "large" या 0 से अधिक अंक {_1} तक होना चाहिए @msg size must be positive size: आकार सकारात्मक होना चाहिए @@ -7251,7 +7340,7 @@ @msg span must be "object" or "selection", got {v} span: स्पैन को "object" या "selection" होना चाहिए, {v} मिला @msg span: "selection" places a `gradient`, not a swatch span: "selection" एक `gradient` रखता है, स्वॉच नहीं @msg span: "selection" places a gradient: give `gradient` span: "selection" ग्रेडिएंट रखता है: `gradient` दें -@msg span: "selection" places a linear or radial gradient, not a freeform one स्पैन: "selection" एक रैखिक या रेडियल ग्रेडिएंट रखता है, एक फ्रीफॉर्म नहीं एक +@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" एक रैखिक या रेडियल ग्रेडिएंट रखता है, फ्रीफॉर्म ग्रेडिएंट नहीं @msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" खुद ग्रेडिएंट रखता है: `start` और `end` न दें (वे उसे डॉक्यूमेंट निर्देशांकों पर रख देते हैं) @msg speed must be accelerated, step or pause speed: गति को तेज, कदम या रोकें होना चाहिए @msg Split Into Grid: select paths large enough for the grid स्प्लिट इनो ग्रिड: ग्रिड के लिए काफी बड़े पाथ सिलेक्ट करें @@ -7388,6 +7477,11 @@ @msg the image is fully transparent इमेज पूरी तरह से पारदर्शी है @msg the image is not on an artboard; pass rect इमेज एक आर्टबोर्ड पर नहीं है; सही होकर गुजरना @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). इमेज को {_1} × {_2} पिक्सेल पर पता लगाया गया था, जिसे {_3} × {_4} ({_5} मेगापिक्सेल) से नीचे बढ़ाया गया था। +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Flat लोगो मोड keepColors का उपयोग नहीं करता: यह logoColors में बताए गए रंगों को या अपने पाए गए रंगों को ट्रेस करता है। +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). कुछ भी ट्रेस नहीं हुआ: ट्रेस में मिला कोई भी रंग keepColors ({_1}) से मेल नहीं खाता। +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. कुछ भी ट्रेस नहीं हुआ: Ignore रंग keepColors में सूचीबद्ध रंगों के सभी क्षेत्र छोड़ देता है। +@msg Nothing was traced: the colors that match keepColors produce no paths. कुछ भी ट्रेस नहीं हुआ: keepColors से मेल खाने वाले रंग कोई पाथ नहीं बनाते। +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: ट्रेस में मिला कोई भी रंग {c} से मेल नहीं खाता, इसलिए इसका कुछ भी ट्रेस नहीं होता। @msg the image's pixels are missing इमेज के पिक्सेल लापता हैं @msg the image's pixels aren't in the document इमेज के पिक्सेल डॉक्यूमेंट में नहीं हैं @msg the image's pixels can't be read इमेज के पिक्सेल को पढ़ा नहीं जा सकता है @@ -7592,7 +7686,7 @@ @msg tileRange of {what}: {e} {what} का tileRange: {e} @msg time budget समय बजट @msg tintReduction must be from 0 to 100 % tintReduction 0 से 100 % तक होना चाहिए -@msg title can't be empty शीर्षक खाली नहीं हो सकता है +@msg title can't be empty title खाली नहीं हो सकता @msg to (the new name) is required (नया नाम) के लिए आवश्यक है @msg to must be fill or stroke to: होना चाहिए भरने या स्ट्रोक होना चाहिए @msg to must be smooth or corner to: होना चाहिए चिकनी या कोने होना @@ -7757,7 +7851,7 @@ @msg VectorCraft's Fonts folder isn't set here: the desktop app and vectorcraft-cli set it VectorCraft के फ़ॉन्ट फ़ोल्डर यहां सेट नहीं है: डेस्कटॉप ऐप और VectorCraft-क्लाई ने इसे सेट किया @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files संस्करण {0} की फ़ाइलें संपीड़ित नहीं हो सकतीं: केवल संस्करण {COMPRESSED_SINCE} और बाद के संस्करण संपीड़ित फ़ाइलें खोलते हैं @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio को 0.1 से 10 के बीच संख्या होना चाहिए -@msg view is required देखने की आवश्यकता है +@msg view is required view आवश्यक है @msg view must be one of {_1} view: राय होना चाहिए {_1} @msg warpStyle can only edit an applied Warp effect warpStyle केवल लागू वार्प इफ़ेक्ट एडिट कर सकता है @msg width and height are at least 1 pixel चौड़ाई और ऊंचाई कम से कम 1 पिक्सेल हैं @@ -7776,7 +7870,7 @@ @msg Your unsaved changes are kept in this browser: reload the page to get them back. इस ब्राउज़र में आपके बिना सेव किए गए बदलाव को रखा गया है: उन्हें वापस पाने के लिए पेज को फिर से लोड करें। @msg zipped 32-bit PSD image data with prediction can't be read भविष्यवाणी के साथ 32-bit PSD इमेज डेटा zipped नहीं किया जा सकता @msg zone must be a string zone: क्षेत्र एक स्ट्रिंग होना चाहिए -@msg zone must be tab, center, left, right, top or bottom zone: क्षेत्र टैब होना चाहिए, केंद्र, बाएं, दाएं, ऊपर या नीचे +@msg zone must be tab, center, left, right, top or bottom zone को tab, center, left, right, top या bottom होना चाहिए @msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone: जोन को एक एंकर की आवश्यकता होती है: एंकर के साथ एक पैनल के बगल में, या साइड (स्टार्ट या एंड) के साथ एक नए डॉक कॉलम में @msg zoom must be positive zoom: ज़ूम सकारात्मक होना चाहिए @msgplural ({n} linked file not found)|({n} linked files not found) ({n} लिंक्ड फ़ाइल नहीं मिली)|({n} लिंक्ड फ़ाइलें नहीं मिलीं) @@ -7960,3 +8054,50 @@ swatch White, Black सफ़ेद, काला swatch Yellow पीला swatch Yellow Green पीला हरा theme Appearance अपीयरेंस + +# Safe Mode + Safe Mode… सुरक्षित मोड… + Safe Mode सुरक्षित मोड + Help › Safe Mode… मदद › सुरक्षित मोड… + Enable on Re-launch अगली बार शुरू होने पर सक्षम करें + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. VectorCraft का पिछला लॉन्च प्लग-इन {name} के लोड होते समय रुक गया। हो सकता है कि वह इसका कारण न हो। + Disable and Continue अक्षम करें और जारी रखें + Start Normally सामान्य रूप से शुरू करें + Start in Safe Mode सुरक्षित मोड में शुरू करें + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft सुरक्षित मोड में है: जब तक यह सामान्य रूप से शुरू नहीं होता, कोई भी प्लग-इन लोड या चलाया नहीं जाता। + Disabled plug-ins अक्षम प्लग-इन + No plug-ins are disabled. कोई प्लग-इन अक्षम नहीं है। +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally सुरक्षित मोड चालू है: जब तक VectorCraft सामान्य रूप से शुरू नहीं होता, प्लग-इन लोड या चलाए नहीं जाते +@msg give `id` or `file` (see plugin.safeMode) `id` या `file` दें (plugin.safeMode देखें) +@msg that plug-in isn't disabled (see plugin.safeMode) वह प्लग-इन अक्षम नहीं है (plugin.safeMode देखें) +@msg Couldn't save the disabled plug-ins for the next launch अगली बार लॉन्च के लिए अक्षम प्लग-इन सहेजे नहीं जा सके +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode उपस्थिति का विस्तार: सिलेक्शन में किसी प्लग-इन इफ़ेक्ट का सुरक्षित मोड में विस्तार नहीं किया जा सकता + +# Align to Pixel Grid + Align to Pixel Grid पिक्सेल ग्रिड से संरेखित करें +@msg only paths and compound paths can be aligned to the pixel grid केवल पाथ और कंपाउंड पाथ को पिक्सेल ग्रिड से संरेखित किया जा सकता है +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding नाम `{name}` UTF-8 नहीं है और इसे {label} के रूप में पढ़ा गया: यदि यह गलत दिखे, तो फ़ाइल को किसी अन्य legacyNameEncoding से खोलें +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding नाम `{name}` UTF-8 नहीं है, और इसके कुछ हिस्से {label} में भी मान्य नहीं हैं: फ़ाइल को किसी अन्य legacyNameEncoding से खोलें +@msg more names that aren't UTF-8 were read as {label} ({count}) UTF-8 में न होने वाले और नाम {label} के रूप में पढ़े गए ({count}) + Start Lists From Typed Markers टाइप किए गए चिह्नों से सूचियाँ शुरू करें + Marquee Selection मार्की चयन + Touching स्पर्श करते हुए + Fully Enclosed पूरी तरह घिरे हुए + Drag Direction खींचने की दिशा + +# 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 plug-in of kind `{_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` छोड़ दें +@msg side is left or right, not `{s}` side left या right होना चाहिए, `{s}` नहीं diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a400c495f..1430073e1 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -280,6 +280,10 @@ Anti-Alias Élsimítás Anti-alias Rasters Raszterek élsimítása Anti-aliased Artwork Élsimított grafika + Anti-aliasing Method Élsimítási módszer +antiAlias Sharp Éles + Crisp Tiszta + Strong Erős Anti-aliasing: Élsimítás: Any except extracting pages Bármi, kivéve az oldalak kinyerését Any Kind Bármilyen típus @@ -504,6 +508,13 @@ font-technology Variable Változó Apply Bullets Felsorolásjelek alkalmazása Numbered List Számozott lista Apply Numbers Számozás alkalmazása + Bullets and Numbering… Felsorolásjelek és számozás… + Numbers Számok + Level: Szint: + Bullet Character: Felsorolásjel: + Number Style: Számozás stílusa: + Left Indent: Bal behúzás: + First Line Indent: Első sor behúzása: Convert to Text Átalakítás szöveggé Filled Dot Kitöltött pont Dash Gondolatjel @@ -731,6 +742,11 @@ font-technology Variable Változó Color Table ({n} colors) Színtábla ({n} szín) Color Theme Színtéma Color Themes Színtémák + Color Wheel Színkör + Hue ring Színárnyalat-gyűrű + Saturation and brightness Telítettség és fényesség + Color field Színmező + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Színtípus: Color: Szín: Colorization Színezés @@ -1111,6 +1127,7 @@ font-technology Variable Változó Double Bar Kettős vonal Double Click To Isolate Elkülönítés dupla kattintással Double Column Két oszlop + Keep Tools Beside the Canvas Eszközök a vászon mellett Double Left Quotation Marks Bal oldali dupla idézőjel Double Quotes: Dupla idézőjelek: Double Right Quotation Marks Jobb oldali dupla idézőjel @@ -1144,7 +1161,7 @@ font-technology Variable Változó Drag to float as a toolbar Húzd el, hogy önálló eszköztárként lebegjen Drag to float the toolbar Húzd el az eszköztár lebegtetéséhez Drag to move Húzással áthelyezhető - Drag to the window's left edge to dock Dokkoláshoz húzd az ablak bal szélére + Drag to the window's left or right edge to dock Dokkoláshoz húzd az ablak bal vagy jobb szélére Step up or down Léptetés fel vagy le Drag values or labels. Double-click a value to type. Húzd az értékeket vagy a címkéket. Gépeléshez kattints duplán egy értékre. Draw Rajzolás @@ -1367,6 +1384,14 @@ font-technology Variable Változó Export As SVG… Exportálás SVG-ként… Export Asset Elem exportálása Export Assets Elemek exportálása + Export 3D Object 3D objektum exportálása + 3D Export Format 3D exportálási formátum + Export 3D Assets 3D elemek exportálása +@msg {what} can't be exported in 3D: {e} {what} nem exportálható 3D-ben: {e} +@msg asset {id} isn't a 3D asset a(z) {id} elem nem 3D elem +@msg asset {id} is a 3D asset: assets.export3D writes it a(z) {id} elem 3D elem: az assets.export3D írja ki +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) még nincs 3D elem: előbb gyűjtsön össze egy 3D effektussal rendelkező objektumot (assets.add3D) +@msg select an object with a 3D effect (or give its ids) jelöljön ki egy 3D effektussal rendelkező objektumot (vagy adja meg az azonosítóit) Export As… Exportálás másként… Export CSS CSS exportálása Export CSS… CSS exportálása… @@ -1400,6 +1425,17 @@ font-technology Variable Változó Exporting {} Exportálás: {} Export… Exportálás… Extrude & Bevel… Kihúzás és élletörés… + 3D (Classic) 3D (klasszikus) + Extrude & Bevel (Classic)… Kihúzás és élletörés (klasszikus)… + Surface Felület + Wireframe Drótváz + No Shading Nincs árnyékolás + Plastic Műanyag + Highlight Intensity Csillanás erőssége + Highlight Size Csillanás mérete + Blend Steps Átmenetlépések + Shading Color Árnyékolás színe + Draw Hidden Faces Rejtett lapok rajzolása Eyedropper Pipetta Sample Color from Screen Szín mintavétele a képernyőről This system has no screen color sampler Ezen a rendszeren nincs képernyős színmintavevő @@ -1467,6 +1503,7 @@ digits Malayalam Malajálam Highlight: Kiemelés: Stroke When Editable Körvonal, ha szerkeszthető Fill New Type Objects With Placeholder Text Új szövegobjektumok kitöltése helykitöltő szöveggel + Start Lists From Typed Markers Listák indítása begépelt jelölőkből Fill Rule Kitöltési szabály Fill with Placeholder Text Kitöltés helykitöltő szöveggel Fill: Kitöltés: @@ -1484,6 +1521,7 @@ digits Malayalam Malajálam Find in Folder… Keresés mappában… Find Next Következő keresése Find Objects Objektumok keresése + Compare with Image Összehasonlítás képpel Fine Dots Finom pontok Simple Egyszerű @@ -2153,6 +2191,7 @@ file-filter Templates Sablonok Report copied Jelentés másolva Line {line} {line}. sor Line {line}: {name}: {status} {line}. sor: {name}: {status} + Read Action Set File Műveletkészlet beolvasása fájlból Read Shortcut Set File Billentyűparancs-készlet beolvasása fájlból Import Matches Importálási megfeleltetések Remember Import Matches Importálási megfeleltetések megjegyzése @@ -2164,6 +2203,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 @@ -3092,6 +3132,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} @@ -3294,6 +3337,7 @@ mark-style Japanese Japán Reset Transformation Átalakítás visszaállítása Replace With Font Helyettesítő betűtípus Replace With Font From Helyettesítő betűtípus forrása + Find In Keresés helye System Rendszer Replace “{name}”, the file this document was opened from? Felülírod ezt a fájlt, amelyből a dokumentumot megnyitottad: „{name}”? Replaces the saved preset of that name. Felülírja az azonos nevű mentett előbeállítást. @@ -3665,6 +3709,11 @@ mark-style Japanese Japán Selected Swatches Only Csak a kijelölt színminták Selected Swatches Only (0) Csak a kijelölt színminták (0) Selection Kijelölés +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Téglalapos kijelölés + Touching Érintett objektumok + Fully Enclosed Teljesen befoglalt + Drag Direction Húzás iránya Selection & Anchor Display Kijelölés és horgonypontok megjelenítése Selection Only Csak a kijelölés Selection Tool Kijelölés @@ -4073,6 +4122,9 @@ mark-style Japanese Japán Symbols Szimbólumok System Layout Rendszerszintű elrendezés System Title Bar Rendszer címsora + VectorCraft Title Bar on Every Desktop A VectorCraft címsora minden asztali környezetben + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Amíg a VECTORCRAFT_TITLEBAR környezeti változó be van állítva, az választja ki a címsort. + System Title Bar takes precedence over this option while it is on. Amíg a Rendszer címsora be van kapcsolva, elsőbbséget élvez ezzel a beállítással szemben. Tab Tabulátor Tab Stops Tabulátorpontok Table Táblázat @@ -4095,6 +4147,15 @@ mark-style Japanese Japán Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Tate-chu-yoko beállításai… Tate-chu-yoko Settings Tate-chu-yoko beállításai + Composite Fonts… Összetett betűtípusok… + Composite Fonts Összetett betűtípusok + Export Composite Fonts Összetett betűtípusok exportálása + Import Composite Fonts Összetett betűtípusok importálása + Delete Font Betűtípus törlése + Kanji Kandzsi + Kana Kana + Numerals Számjegyek + Scale at Center Méretezés középről Up/Down: Fel/le: Left/Right: Balra/jobbra: Technical Drawing Műszaki rajz @@ -4169,6 +4230,7 @@ mark-style Japanese Japán tile {number} csempe {number} Tiles are numbered across, then down; leave the range empty for every tile. A csempék számozása vízszintesen, majd lefelé halad; az összes csempéhez hagyd üresen a tartományt. Tilt Dőlés + Copies per Sheet (as many as fit, in a grid) Példány laponként (ahány elfér, rácsban) Tint Tónus Tints Tónusok Tints and Shades Tónusok és árnyalatok @@ -4525,6 +4587,7 @@ mark-style Japanese Japán Zoom to Selection Nagyítás a kijelölésre Zoom Tool Nagyító Zoom with Mouse Wheel Nagyítás egérgörgővel + Scroll Sideways with Cmd/Ctrl + Wheel Vízszintes görgetés Cmd/Ctrl + görgővel @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? Fájlok: {n}, mappa: {folder} {note}. Megnyitod a csomag mappáját? @plural {n} folder or file couldn't be read|{n} folders or files couldn't be read Nem olvasható mappák vagy fájlok: {n} @plural {n} folder skipped, such as another app's or the system's folder or a hidden one|{n} folders skipped, such as other apps' and the system's folders and hidden ones Kihagyott mappák: {n} (például más alkalmazások, a rendszer és a rejtett mappák) @@ -4601,6 +4664,7 @@ imageTrace Outline Körvonal # Status and error messages @msg "{0}" is not a colour: write it as #rrggbb nem szín: "{0}"; #rrggbb alakban add meg +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias`: "none", "sharp", "crisp" vagy "strong" kell @msg `aspect` must be a number (%) `aspect`: szám kell (%) @msg `at` must be [x, y] `at`: [x, y] kell @msg `color` must be a colour `color`: szín kell @@ -4726,6 +4790,7 @@ imageTrace Outline Körvonal @msg an image VectorCraft can't read was left out kimaradt egy kép, amelyet a VectorCraft nem tud olvasni @msg graphs come in as plain art, without their data or settings a diagramok egyszerű grafikaként kerülnek be, adataik és beállításaik nélkül @msg an image whose data is cut short was left out kimaradt egy csonka adatú kép +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers egy {_1} × {_2} képpontos beágyazott kép {_3} × {_4} képpontosra lett kicsinyítve, hogy a rétegek megmaradjanak @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel a csatolt képek a fájlban mentett kis felbontású előnézetet mutatják, amíg a fájljaik elő nem kerülnek: csatold őket újra a Csatolások panelen @msg linked type areas past VectorCraft's limits were left out a VectorCraft korlátain túli csatolt szövegterületek kimaradtak @msg type that changes writing direction inside a text object opens in the direction of its first character a szövegobjektumon belül írásirányt váltó szöveg az első karakterének irányában nyílik meg @@ -4818,7 +4883,7 @@ imageTrace Outline Körvonal @msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Az adat-helyreállításnak nincs hova mentenie a másolatokat: állítsd be a Beállítások → Fájlkezelés → Adat-helyreállítás → Mappa értéket (recoveryFolder) @msg Data Recovery can't keep copies in {path}: {e} Az adat-helyreállítás nem tud másolatokat menteni ide: {path}: {e} @msg Plug-ins can't be loaded from {path}: {e} Innen nem tölthetők be bővítmények: {path}: {e} -@msg decimals must be {_1}–{_2} (got {_3}) a tizedesjegyek száma {_1}–{_2} lehet (kapott érték: {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) a decimals értéke {_1}–{_2} lehet (kapott érték: {_3}) @msg direct-select anchor points to cut at (not the end points of open paths) jelöld ki közvetlen kijelöléssel a vágási horgonypontokat (nyitott útvonal végpontja nem lehet) @msgplural Discarded {n} recovered document|Discarded {n} recovered documents Elvetett helyreállított dokumentumok: {n} @msg divide into 1 to {MAX_PIECES} slices a felosztás 1 és {MAX_PIECES} közötti szeletre lehetséges @@ -5028,6 +5093,8 @@ imageTrace Outline Körvonal @msg no such action set nincs ilyen műveletkészlet @msg the action sets changed; nothing was deleted a műveletkészletek megváltoztak; semmi sem törlődött @msg no action set to save nincs menthető műveletkészlet +@msg the action set file can't be read in full a műveletkészlet-fájl nem olvasható be teljesen +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}” betöltve; kihagyott lépések: {n} @msg not an action set file nem műveletkészlet-fájl @msg the action set file is larger than 8 MiB a műveletkészlet-fájl nagyobb 8 MiB-nál @msg the action set file holds more than 1,000 actions a műveletkészlet-fájl több mint 1000 műveletet tartalmaz @@ -5094,6 +5161,7 @@ imageTrace Outline Körvonal @msg object ids are non-negative integers, not {v} az objektumazonosító nemnegatív egész szám, ez nem az: {v} @msg {key} must be an array of non-negative integer object ids {key}: objektumazonosítók tömbje kell, mindegyik nemnegatív egész szám @msg invalid object id {value}: expected a non-negative integer érvénytelen objektumazonosító: {value} (nemnegatív egész szám kell) +@msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` {index} indexű eleme {value}, nem objektumazonosító (nemnegatív egész szám kell) @msg no such object id {value} nincs ilyen objektumazonosító: {value} @msg id {value} is a layer, not an object a(z) {value} azonosító egy réteg, nem objektum @msg id {value} is a layer, not an object; move layers with layer.move a(z) {value} azonosító egy réteg, nem objektum; a rétegeket a layer.move paranccsal helyezheti át @@ -5101,7 +5169,10 @@ imageTrace Outline Körvonal @msg object {id} is not a path az objektum nem útvonal: {id} @msg object {id} is not a selected top-level object az objektum nem kijelölt legfelső szintű objektum: {id} @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) a VectorCraft által még nem olvasható ecsetek kimaradtak az Ecsetek panelről ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet a több kitöltésű vagy körvonalú, effektusos vagy ecsetes objektumok a megrajzolt kinézetükkel kerültek be ({_1}): a megjelenésük még nem szerkeszthető +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet a több kitöltésű vagy körvonalú, változó szélességű körvonalú, effektusos vagy ecsetes objektumok a megrajzolt kinézetükkel kerültek be ({_1}): a megjelenésük még nem szerkeszthető +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding a(z) `{name}` név nem UTF-8, ezért {label} kódolással lett beolvasva: ha hibásnak tűnik, nyissa meg a fájlt másik legacyNameEncoding értékkel +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding a(z) `{name}` név nem UTF-8, és egyes részei {label} kódolásban sem érvényesek: nyissa meg a fájlt másik legacyNameEncoding értékkel +@msg more names that aren't UTF-8 were read as {label} ({count}) további nem UTF-8 nevek lettek {label} kódolással beolvasva ({count}) @msg Offset Path: select paths Útvonal eltolása: jelölj ki útvonalakat @msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Útvonal eltolása: az eltolás után semmi sem marad a kijelölt útvonalakból (a befelé eltolás nagyobb az alakzatoknál) @msg onto must name a floating panel az onto egy lebegő panelt kell megnevezzen @@ -5189,8 +5260,8 @@ imageTrace Outline Körvonal @msg Saving Mentés @msg Saving recovery data for {_1} Helyreállítási adatok mentése: {_1} @msg Saving {_1} Mentés: {_1} -@msg scale must be a number from {_1} to {_2}, not {_3} a méretarány {_1} és {_2} közötti szám lehet, ez nem az: {_3} -@msg scale must be a number from {_1} to {_2}, not {s} a méretarány {_1} és {_2} közötti szám lehet, ez nem az: {s} +@msg scale must be a number from {_1} to {_2}, not {_3} a scale {_1} és {_2} közötti szám lehet, ez nem az: {_3} +@msg scale must be a number from {_1} to {_2}, not {s} a scale {_1} és {_2} közötti szám lehet, ez nem az: {s} @msg searching a folder isn't available on the web a webes változatban nem lehet mappában keresni @msg select (or target) objects whose appearance isn't basic jelölj ki (vagy célozz meg) olyan objektumokat, amelyeknek nem alapmegjelenésük van @msg select a blend jelölj ki egy összemosást @@ -5215,6 +5286,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 @@ -5222,7 +5300,7 @@ imageTrace Outline Körvonal @msg select paths or anchor points jelölj ki útvonalakat vagy horgonypontokat @msg select paths to join jelöld ki az összekapcsolandó útvonalakat @msg select some type first előbb jelölj ki szöveget -@msg kerning must be a number, "auto", or "optical" Az alávágás szám, „auto” vagy „optical” lehet +@msg kerning must be a number, "auto", or "optical" a kerning szám, „auto” vagy „optical” lehet @msg the selection has no gradient fill a kijelölésnek nincs színátmenetes kitöltése @msg the selection has no gradient stroke a kijelölésnek nincs színátmenetes körvonala @msg the selection has no pattern fill a kijelölésnek nincs mintázatos kitöltése @@ -5317,6 +5395,10 @@ 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 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}) @msg the PCX file is cut short a PCX-fájl csonka @msg the PCX file's header is damaged a PCX-fájl fejléce sérült @@ -5326,6 +5408,9 @@ imageTrace Outline Körvonal @msg the Pixar file's image data is damaged a Pixar-fájl képadatai sérültek @msg the JPEG 2000 file can't be read a JPEG 2000 fájl nem olvasható @msg the JPEG 2000 file has an unsupported color space or JPX content a JPEG 2000 fájl nem támogatott színteret vagy JPX-tartalmat tartalmaz +@msg the HEIC or HEIF file can't be read a HEIC- vagy HEIF-fájl nem olvasható +@msg the HEIC or HEIF file holds a kind of image that can't be read a HEIC- vagy HEIF-fájl olyan képtípust tartalmaz, amely nem olvasható +@msg HEIC and HEIF images can't be opened in this build a HEIC- és HEIF-képek ebben a buildben nem nyithatók meg @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 @msg the scale is not a usable number a méretarány nem használható szám @@ -5495,6 +5580,11 @@ imageTrace Outline Körvonal @msg unknown parameter `{key}`; the command takes: {keys} ismeretlen paraméter: `{key}`; a parancs ezeket fogadja el: {keys} Load EPS into Clipboard EPS betöltése a vágólapra @msg an image of {w} × {h} pixels is too large to draw egy {w} × {h} pixeles kép túl nagy a kirajzoláshoz +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: nem PNG, JPEG, WebP, TIFF vagy BMP kép +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ a kép {iw} × {ih} pixeles, a rajztábla ilyen szélességben {rw} × {rh} pixeles: az arányaik eltérnek +@msg `threshold` must be a number from 0 to 1 a `threshold` értéke 0 és 1 közötti szám lehet +@msg background {v}: a color such as "#808080" background {v}: egy szín, például "#808080" +@msg comparing with an image file isn't available in the browser a képfájllal való összehasonlítás a böngészőben nem érhető el @msg an image of {w} × {h} pixels is shown at reduced size egy {w} × {h} pixeles kép csökkentett méretben jelenik meg @msg the image can't be read as a texture a kép nem olvasható be textúraként Fill and Stroke Controls Kitöltés és körvonal vezérlői @@ -5688,11 +5778,19 @@ imageTrace Outline Körvonal @msg too many library panels were opened in this session ebben a munkamenetben túl sok könyvtárpanel nyílt meg @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. A kép {_1} × {_2} képpont, egyik oldalán több mint {LARGE_TRACE_SIDE}: a vektorizálás megtörténik, de lassabban. Kisebb hűségű előbeállítással vagy a kép kisebb másolatával gyorsabb. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). A kép vektorizálása {_1} × {_2} képponton történt, {_3} × {_4} méretről kicsinyítve (legfeljebb {_5} megapixel). +@msg byColor must be true or false a byColor értéke true vagy false lehet +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping a byColor nem bontja fel az Automatikus csoportosítás csoportjait: vektorizáljon Automatikus csoportosítás nélkül +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor a byColor nem csoportosíthatja át az élő Pathfinder-effektussal rendelkező vektorizálást: bővítse ki byColor nélkül Snapping Illesztés Snap to Tangent Illesztés érintőre Snap to Perpendicular Illesztés merőlegesre @msg unknown snap option `{key}` (use {options}) ismeretlen illesztési beállítás: `{key}` (használható: {options}) Gap Length: Rés hossza: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Az Egyszínű logó mód nem használja a keepColors beállítást: a logoColors beállításban megadott színeket vagy az általa talált színeket vektorizálja. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Semmi sem lett vektorizálva: a vektorizálás által talált színek egyike sem felel meg a keepColors beállításnak ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Semmi sem lett vektorizálva: a Szín figyelmen kívül hagyása kihagyja a keepColors beállításban felsorolt színek minden területét. +@msg Nothing was traced: the colors that match keepColors produce no paths. Semmi sem lett vektorizálva: a keepColors beállításnak megfelelő színekből nem jön létre útvonal. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: a vektorizálás által talált színek egyike sem felel meg ennek: {c}, így belőle semmi sem lesz vektorizálva. @msg `colorGroup` needs `library: "document"` a `colorGroup` használatához `library: "document"` szükséges @msg `colorGroup` needs `palette: "documentLibrary"` a `colorGroup` használatához `palette: "documentLibrary"` szükséges @msg `colorGroup` needs `mode: "color"` a `colorGroup` használatához `mode: "color"` szükséges @@ -5804,11 +5902,14 @@ axis Y Axis Y tengely @msg Running “{name}”… Futtatás: „{name}”… @msg a live effect runs while the document is drawn, so it can't be `heavy` az élő effektus a dokumentum rajzolása közben fut, ezért nem lehet `heavy` @msg an exporter always gets an export's budget: leave `budget` out az exportáló bővítmény mindig az exportálás keretét kapja: hagyd ki a `budget` kulcsot -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees a(z) „{_1}” nagy számítási keretet kér (futásonként legfeljebb {minutes} perc és a szokásos utasításszám {times}-szorosa): telepítsd az `allowHeavy: true` megadásával, miután a felhasználó beleegyezett +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants a(z) „{_1}” nagy számítási keretet kér (futásonként legfeljebb {minutes} perc és a szokásos utasításszám {times}-szorosa): egy személynek jóvá kell hagynia az asztali alkalmazásban, vagy add meg az `allowHeavy: true` értéket egy --allow-plugin-grants kapcsolóval indított munkamenetben # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Telepíted a(z) „{name}” bővítményt, amely ezeket az engedélyeket kéri? ID: {id} Azonosító: {id} Version: {version} Verzió: {version} + Install “{name}” in place of the plug-in with the same ID? Telepíted a(z) „{name}” bővítményt az azonos azonosítójú bővítmény helyére? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ez a fájl nem az a bővítmény, amely korábban ezzel az azonosítóval volt telepítve. Megkapja az ehhez az azonosítóhoz tárolt beállításokat és gyorsítótárat. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees a(z) „{name}” más modul, mint a(z) {_1} azonosítójú bővítmény, és megkapná az ahhoz tárolt beállításokat és gyorsítótárat: telepítsd a `replace: true` megadásával, miután a felhasználó beleegyezett File SHA-256: {hash} A fájl SHA-256 értéke: {hash} Read the objects it is given Olvashatja a kapott objektumokat Change, add and delete objects in the document Módosíthat, hozzáadhat és törölhet objektumokat a dokumentumban @@ -5816,21 +5917,43 @@ 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 Show its progress, see Cancel and write log lines Megjelenítheti az előrehaladását, észlelheti a megszakítást és naplósorokat írhat -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a `progress` engedélyhez {PROGRESS_API}. szintű bővítmény-API kell: adja meg az `api` {PROGRESS_API} értéket + Keep settings of its own Saját beállításokat tárolhat + Keep a cache of its own, which you can clear Saját gyorsítótárat tárolhat, amelyet Ön kiüríthet + Clear Plug-in Cache Bővítmény gyorsítótárának kiürítése +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a(z) `{permission}` engedélyhez {PROGRESS_API}. szintű bővítmény-API kell: adja meg az `api` {PROGRESS_API} értéket @msg the module imports `env.{name}` with another type than the host's: it is {signature} a modul az `env.{name}` elemet a gazdaprogramétól eltérő típussal importálja: a gazdaprogram típusa {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` a modul importálja az `env.{name}` elemet, amelyet bővítmény csak {PROGRESS_API}. szintű bővítmény-API-val (`api` {PROGRESS_API}) és a `progress` engedéllyel használhat -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network a modul importálja a(z) `{from}.{name}` elemet, amelyet a gazdaprogram nem kínál: bővítmény csak az `env.vc_progress`, `env.vc_cancelled` és `env.vc_log` elemet importálhatja (2. szintű bővítmény-API), WASI-t, fájlokat és hálózatot nem +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` a modul importálja az `env.{name}` elemet, amelyet bővítmény csak {PROGRESS_API}. szintű bővítmény-API-val (`api` {PROGRESS_API}) és a(z) `{permission}` engedéllyel használhat +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network a modul importálja a(z) `{from}.{name}` elemet, amelyet a gazdaprogram nem kínál: bővítmény csak a docs/plugins.md által felsorolt `env.vc_*` függvényeket importálhatja (2. szintű bővítmény-API), WASI-t, fájlokat és hálózatot nem @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` may only be true A `paintOutput` értéke csak true lehet +@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 @msg at most {MAX_PERMISSIONS} `permissions` legfeljebb {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` a(z) `{shown}` engedély nem vonatkozik `{_1}` fajtájú bővítményre @msg permission `{shown}` is listed twice a(z) `{shown}` engedély kétszer szerepel +@msg only a filter declares `inputs` `inputs` kulcsot csak szűrő adhat meg +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance az `inputs` ismeretlen objektumfajtát nevez meg: `{shown}`; használja a type, image vagy symbolInstance értéket +@msg `inputs` lists `{shown}` twice az `inputs` kétszer sorolja fel ezt: `{shown}` +@msg `inputs` needs `api` 2 or later az `inputs` használatához `api` 2 vagy újabb szükséges +@msg `inputs` needs the permission `doc.read` az `inputs` használatához a(z) `doc.read` engedély szükséges +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) memóriakeret (a(z) {_1} objektumnak {size} bájt bemenet kell, {left} maradt) +@msg object budget (more than {MAX_OBJECTS} read-only objects) objektumkeret (több mint {MAX_OBJECTS} csak olvasható objektum) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a(z) `{shown}` engedély egy későbbi bővítmény-API számára van fenntartva; ez a gazdaprogram nem adja meg @msg unknown permission `{shown}` ismeretlen engedély: `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees a(z) „{name}” a következő engedélyeket kéri: {asked}; telepítsd egy őket felsoroló `grant` értékkel, ha a felhasználó beleegyezik +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants a(z) „{name}” a következő engedélyeket kéri: {asked}; egy személynek jóvá kell hagynia őket az asztali alkalmazásban, vagy add meg a `grant` értéket egy --allow-plugin-grants kapcsolóval indított munkamenetben +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app a `grant` és az `allowHeavy` csak --allow-plugin-grants kapcsolóval indított munkamenetben érvényes: hagyd jóvá a bővítményt az asztali alkalmazásban +@msg Only the person at the keyboard can approve installing a plug-in Bővítmény telepítését csak a billentyűzetnél ülő személy hagyhatja jóvá @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a(z) „{name}” nem kapta meg a(z) `{p}` engedélyt, amelyre a kimenetének szüksége van: semmi sem változott +@msg a heavy filter runs in the background, so it can't declare `commands` a nehéz szűrő a háttérben fut, ezért nem deklarálhat `commands` értéket +@msg at most {MAX_COMMANDS} `commands` legfeljebb {MAX_COMMANDS} `commands` +@msg command {name} is declared twice a(z) {name} parancs kétszer van deklarálva +@msg command {name}: `label` must be 1-64 printable characters {name} parancs: a `label` 1-64 nyomtatható karakter legyen +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter {name} parancs: használj 1-{MAX_COMMAND_ID} karakteres, a-z 0-9 _ karakterekből álló, betűvel kezdődő azonosítót +@msg command {name}: {e} {name} parancs: {e} +@msg only a filter or a live effect declares `commands` `commands` értéket csak szűrő vagy élő effektus deklarálhat +@msg “{name}” › {label}: {e} „{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed a modul SHA-256 értéke nem az, amit a `hash` megad: semmi sem lett telepítve # Actions panel: action sets (#3712). New Set… Új készlet… @@ -6249,6 +6372,8 @@ breakText Glyphs Glifák File + Data Set Name Fájl + adatkészlet neve Data Set Name Adatkészlet neve @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` A `datasetFileNaming` értéke `fileNumber`, `fileDataSetName` vagy `dataSetName` lehet + Each data set saves over the same file, which keeps only the last data set. Minden adatkészlet ugyanabba a fájlba mentődik, amely csak az utolsó adatkészletet őrzi meg. +@msg save the document with Save As before Save and Close mentse a dokumentumot a „Mentés másként” paranccsal a „Mentés és bezárás” előtt @msg no free data set file name in destination folder Nincs szabad adatkészlet-fájlnév a célmappában # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6368,9 +6493,20 @@ breakText Glyphs Glifák Reset Symbol Instance Szimbólumpéldány visszaállítása @msg type must be static or dynamic, not {v} a type értéke static vagy dynamic lehet, nem {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` statikus szimbólum: előbb állítsa a szimbólum típusát Dinamikusra +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Egy szimbólumpéldányban kijelölt útvonalnak, összetett útvonalnak vagy szövegnek csak a kitöltés és a körvonal egyszínű színe módosítható @msg path must be a list of child indices a path gyermekindexek listája kell legyen @msg no object at path {shown} in the symbol's art a szimbólum grafikájában nincs objektum a(z) {shown} útvonalon @msg give fill, stroke, opacity and/or blend adja meg a fill, stroke, opacity és/vagy blend értékét +@msg {key} cannot be set on a guide a {key} nem állítható be segédvonalon +@msg {key} cannot be set on a blend a {key} nem állítható be összemosáson +@msg {key} cannot be set on an envelope a {key} nem állítható be burkolón +@msg {key} cannot be set on a gradient mesh a {key} nem állítható be színátmenetes hálón +@msg {key} cannot be set on a Repeat a {key} nem állítható be ismétlésen +@msg {key} cannot be set on a placed document a {key} nem állítható be elhelyezett dokumentumon +@msg {key} cannot be set on an Intertwine group a {key} nem állítható be összefonási csoporton +@msg stroke cannot be set on a symbol instance a stroke nem állítható be szimbólumpéldányon +@msg fill on a symbol instance must be a solid color or none a szimbólumpéldány fill értéke csak egyszínű szín vagy none lehet +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it a {key} nem állítható be olyan képen, amelyen nincs Átalakítás alakzattá vagy Objektum útvonallá alakítása effektus @msg pass newName, registration, exportType, nineSlice and/or type adja meg a newName, registration, exportType, nineSlice és/vagy type értékét Text Flow Szövegfolyás By Rows Soronként @@ -6400,6 +6536,7 @@ breakText Glyphs Glifák Create Swatch Legend Színmintajelmagyarázat létrehozása Create Swatch Legend… Színmintajelmagyarázat létrehozása… HEX HEX + Help Bar Súgósáv # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Szövegeszközök Text Tweaker… Szöveghangoló… @@ -6465,3 +6602,44 @@ 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 plug-in of kind `{_2}`, not an importer `{_1}`: `{_2}` fajtájú bővítmény, nem importáló bővítmény +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}`: `{_2}` fajtájú bővítmény, nem exportá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 + +# Safe Mode + Safe Mode… Csökkentett mód… + Safe Mode Csökkentett mód + Help › Safe Mode… Súgó › Csökkentett mód… + Enable on Re-launch Engedélyezés a következő indításkor + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. A VectorCraft legutóbbi indítása leállt, miközben a(z) {name} bővítmény betöltődött. Lehet, hogy nem ez okozta. + Disable and Continue Letiltás és folytatás + Start Normally Normál indítás + Start in Safe Mode Indítás csökkentett módban + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. A VectorCraft csökkentett módban fut: a bővítmények nem töltődnek be és nem futnak, amíg normál módban el nem indul. + Disabled plug-ins Letiltott bővítmények + No plug-ins are disabled. Nincs letiltott bővítmény. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally A csökkentett mód be van kapcsolva: a bővítmények nem töltődnek be és nem futnak, amíg a VectorCraft normál módban el nem indul +@msg give `id` or `file` (see plugin.safeMode) adja meg: `id` vagy `file` (lásd: plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) ez a bővítmény nincs letiltva (lásd: plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch A letiltott bővítményeket nem sikerült menteni a következő indításhoz +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Megjelenés kibontása: a kijelölésben lévő bővítményeffektus csökkentett módban nem bontható ki + +# Align to Pixel Grid + Align to Pixel Grid Igazítás a pixelrácshoz +@msg only paths and compound paths can be aligned to the pixel grid csak útvonalak és összetett útvonalak igazíthatók a pixelrácshoz +@msg side is left or right, not `{s}` a side értéke left vagy right lehet, nem `{s}` diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 0134649ea..d7a17ce50 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -135,6 +135,7 @@ Report copied Laporan disalin Line {line} Baris {line} Line {line}: {name}: {status} Baris {line}: {name}: {status} + Read Action Set File Baca berkas set tindakan Read Shortcut Set File Baca berkas set pintasan Import Matches Impor kecocokan Remember Import Matches Ingat kecocokan impor @@ -172,6 +173,11 @@ Ungroup Buka grup Lock Kunci Selection Seleksi +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Seleksi persegi panjang + Touching Objek tersentuh + Fully Enclosed Sepenuhnya di dalam + Drag Direction Arah seret All Artwork Above Semua karya seni di atas Other Layers Lapisan lain Unlock All Buka kunci semua @@ -293,6 +299,13 @@ Apply Bullets Terapkan poin Numbered List Daftar bernomor Apply Numbers Terapkan penomoran + Bullets and Numbering… Poin dan penomoran… + Numbers Nomor + Level: Tingkat: + Bullet Character: Karakter poin: + Number Style: Gaya nomor: + Left Indent: Indentasi kiri: + First Line Indent: Indentasi baris pertama: Convert to Text Konversi ke teks Filled Dot Titik penuh Dash Tanda pisah @@ -420,6 +433,17 @@ Inflate Gelembungkan Select objects, then pick an object type to give them a 3D effect. Pilih objek, lalu pilih jenis objek untuk memberinya efek 3D. Extrude & Bevel… Ekstrusi & bevel… + 3D (Classic) 3D (klasik) + Extrude & Bevel (Classic)… Ekstrusi & bevel (klasik)… + Surface Permukaan + Wireframe Kerangka kawat + No Shading Tanpa pengarsiran + Plastic Plastik + Highlight Intensity Intensitas kilau + Highlight Size Ukuran kilau + Blend Steps Langkah campuran + Shading Color Warna pengarsiran + Draw Hidden Faces Gambar sisi tersembunyi Revolve… Revolusi… Inflate… Gelembungkan… Materials… Material… @@ -622,6 +646,7 @@ Toolbars Bilah alat Advanced Lanjutan Double Column Kolom ganda + Keep Tools Beside the Canvas Alat di samping kanvas Actions Tindakan Asset Export Ekspor aset Attributes Atribut @@ -629,6 +654,11 @@ Color Warna Color Guide Panduan warna Color Themes Tema warna + Color Wheel Roda warna + Hue ring Cincin rona + Saturation and brightness Saturasi dan kecerahan + Color field Kolom warna + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties Properti CSS Document Info Info dokumen Flattener Preview Pratinjau perata @@ -815,6 +845,9 @@ Stained Glass… Kaca patri… Texturizer… Texturizer… System Title Bar Bilah judul sistem + VectorCraft Title Bar on Every Desktop Bilah judul VectorCraft di setiap desktop + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Selama ditetapkan, variabel lingkungan VECTORCRAFT_TITLEBAR memilih bilah judul. + System Title Bar takes precedence over this option while it is on. Selama aktif, Bilah judul sistem lebih diutamakan daripada opsi ini. Exit Keluar Distort Distorsi Diffuse Glow… Pendar difusi… @@ -1171,7 +1204,7 @@ lighting Light Cahaya X: X: Y: Y: Drag to float the toolbar Seret untuk mengambangkan bilah alat - Drag to the window's left edge to dock Seret ke tepi kiri jendela untuk memasang + Drag to the window's left or right edge to dock Seret ke tepi kiri atau kanan jendela untuk memasang Crystallize Kristalisasi Add Anchor Point Tool Alat tambah titik jangkar Anchor Point Tool Alat titik jangkar @@ -1425,6 +1458,15 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Pengaturan Tate-chu-yoko… Tate-chu-yoko Settings Pengaturan Tate-chu-yoko + Composite Fonts… Font komposit… + Composite Fonts Font komposit + Export Composite Fonts Ekspor font komposit + Import Composite Fonts Impor font komposit + Delete Font Hapus font + Kanji Kanji + Kana Kana + Numerals Angka + Scale at Center Skalakan dari tengah Up/Down: Atas/bawah: Left/Right: Kiri/kanan: Underline Garis bawah @@ -1701,6 +1743,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 @@ -2461,6 +2507,7 @@ digits Malayalam Malayalam Tile Range: Rentang ubin: tile {number} ubin {number} Tiles are numbered across, then down; leave the range empty for every tile. Ubin dinomori menyamping lalu ke bawah; biarkan rentang kosong untuk semua ubin. + Copies per Sheet (as many as fit, in a grid) Salinan per lembar (sebanyak yang muat, dalam kisi) Transverse (the page a quarter turn on the paper) Melintang (halaman diputar seperempat putaran pada kertas) Unprintable margin: Margin yang tidak dapat dicetak: {pages} in all, {count} per copy {pages} total, {count} per salinan @@ -2531,6 +2578,10 @@ digits Malayalam Malayalam < 1 sec < 1 detik [Unnamed] [Tanpa nama] Amount: Jumlah: + Anti-aliasing Method Metode anti-aliasing +antiAlias Sharp Tajam + Crisp Jelas + Strong Kuat Anti-aliasing: Anti-aliasing: Artboard: Artboard: Color Table Tabel warna @@ -2925,6 +2976,9 @@ theme Appearance Tampilan Asset Export Settings Pengaturan ekspor aset Assets Aset Export Assets Ekspor aset + Export 3D Object Ekspor objek 3D + 3D Export Format Format ekspor 3D + Export 3D Assets Ekspor aset 3D Remove Asset Hapus aset Rename Asset Ganti nama aset Fill Rule Aturan isian @@ -3058,6 +3112,7 @@ theme Appearance Tampilan Document JSON JSON dokumen Duplicate Duplikatkan Find Objects Cari objek + Compare with Image Bandingkan dengan gambar Inspect Document Periksa dokumen Inspect Object Periksa objek Begin Undo Group Mulai grup urungkan @@ -3361,6 +3416,7 @@ theme Appearance Tampilan Feet Kaki Files File Fill New Type Objects With Placeholder Text Isi objek teks baru dengan teks placeholder + Start Lists From Typed Markers Mulai daftar dari penanda yang diketik Folder Folder Font Preview Size Ukuran pratinjau font Get Preferences Dapatkan preferensi @@ -3471,6 +3527,7 @@ theme Appearance Tampilan Yards Yard Zoom to Selection Zum ke seleksi Zoom with Mouse Wheel Zum dengan roda mouse + Scroll Sideways with Cmd/Ctrl + Wheel Gulir horizontal dengan Cmd/Ctrl + roda mouse Scrub Numeric Fields by Dragging Atur nilai kolom numerik dengan menyeret Linux Display Server Server tampilan Linux Print Preview Pratinjau cetak @@ -3750,6 +3807,7 @@ effect Regular Reguler Find in Folder… Temukan di folder… Replace With Font Ganti dengan font Replace With Font From Ganti dengan font dari + Find In Cari di System Sistem — substituted by {style} — digantikan oleh {style} — {n} characters from another font — {n} karakter dari font lain @@ -3993,7 +4051,7 @@ imageTrace Outline Kontur @plural {n} stroke|{n} strokes {n} goresan @msg no library `{id}` tidak ada pustaka `{id}` @msg select some type first pilih beberapa teks terlebih dahulu -@msg kerning must be a number, "auto", or "optical" Kerning harus berupa angka, “auto”, atau “optical” +@msg kerning must be a number, "auto", or "optical" kerning harus berupa angka, “auto”, atau “optical” @msg {_1} has no {_2} style {_1} tidak memiliki gaya {_2} @msg a layer couldn't be read satu lapisan tidak dapat dibaca @msg it has more than one artboard memiliki lebih dari satu artboard @@ -4078,6 +4136,7 @@ imageTrace Outline Kontur @msg Split Into Grid: select paths large enough for the grid Bagi menjadi kisi: pilih jalur yang cukup besar untuk kisi @msg Targa files are RGB: colorModel rgb File Targa adalah RGB: colorModel rgb @msg Text Wrap: select a wrap object Pembungkusan teks: pilih objek pembungkus +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` harus "none", "sharp", "crisp" atau "strong" @msg `aspect` must be a number (%) `aspect` harus berupa angka (%) @msg `at` must be [x, y] `at` harus [x, y] @msg `color` must be a colour `color` harus berupa warna @@ -4168,7 +4227,7 @@ imageTrace Outline Kontur @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art, atau type @msg artboard {0} does not exist artboard {0} tidak ada @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard pada skala ini gambar berukuran {w} × {h} pt, melebihi kapasitas kanvas: pilih skala yang lebih kecil atau paskan ke artboard -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` sumbu harus "vertical", "horizontal", atau sudut dalam derajat, bukan `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis harus "vertical", "horizontal", atau sudut dalam derajat, bukan `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" background {other}: transparan, putih, hitam, atau warna seperti "#ff8800" @msg bad color {v} warna tidak valid {v} @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio harus berupa angka dari 0,1 sampai 10 @@ -4318,6 +4377,8 @@ imageTrace Outline Kontur @msg no such action set tidak ada set tindakan seperti itu @msg the action sets changed; nothing was deleted set tindakan telah berubah; tidak ada yang dihapus @msg no action set to save tidak ada set tindakan untuk disimpan +@msg the action set file can't be read in full file set tindakan tidak dapat dibaca seluruhnya +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” dimuat: {n} langkah dilewati @msg not an action set file bukan file set tindakan @msg the action set file is larger than 8 MiB file set tindakan lebih besar dari 8 MiB @msg the action set file holds more than 1,000 actions file set tindakan berisi lebih dari 1.000 tindakan @@ -4383,9 +4444,9 @@ imageTrace Outline Kontur @msg replace: the object to replace was changed or deleted ganti: objek yang akan diganti telah diubah atau dihapus @msg pattern missing pola hilang @msg patterns nested too deeply are filled with mid-grey pola yang bersarang terlalu dalam diisi dengan abu-abu tengah -@msg percent must be above 0 and at most 6400, not {p} persen harus di atas 0 dan paling banyak 6400, bukan {p} +@msg percent must be above 0 and at most 6400, not {p} percent harus di atas 0 dan paling banyak 6400, bukan {p} @msg pick a colour, gradient or pattern to save as a swatch pilih warna, gradien, atau pola untuk disimpan sebagai swatch -@msg plane must be left, right or ground bidang harus kiri, kanan, atau tanah +@msg plane must be left, right or ground plane harus left, right, atau ground @msg plug-in ABI: {0} ABI plug-in: {0} @msg plug-in exceeded its {0} plug-in melampaui {0}-nya @msg plug-in failed: {0} plug-in gagal: {0} @@ -4404,8 +4465,8 @@ imageTrace Outline Kontur @msg result would exceed the canvas (coordinates out of range) hasil akan melampaui kanvas (koordinat di luar rentang) @msg objects can't be nested more than 32 deep objek tidak dapat bersarang lebih dari 32 tingkat @msg the SVG's elements are nested more than {0} deep elemen SVG bersarang lebih dari {0} tingkat -@msg scale must be a number from {_1} to {_2}, not {_3} skala harus berupa angka dari {_1} sampai {_2}, bukan {_3} -@msg scale must be a number from {_1} to {_2}, not {s} skala harus berupa angka dari {_1} sampai {_2}, bukan {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale harus berupa angka dari {_1} sampai {_2}, bukan {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale harus berupa angka dari {_1} sampai {_2}, bukan {s} @msg select (or target) objects whose appearance isn't basic pilih (atau jadikan target) objek yang penampilannya bukan dasar @msg select a Live Paint group pilih grup Live Paint @msg select a blend pilih blend @@ -4645,10 +4706,14 @@ imageTrace Outline Kontur @msg its objects are nested too deeply objeknya bersarang terlalu dalam @msg its section ending with `%{end}` doesn't end bagian yang berakhir dengan `%{end}` tidak berakhir @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) kuas yang belum dapat dibaca VectorCraft tidak dimasukkan ke panel Kuas ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objek dengan beberapa isian atau goresan, efek, atau kuas masuk sebagai tampilan gambarnya ({_1}): penampilannya belum dapat diedit +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objek dengan beberapa isian atau goresan, goresan dengan lebar bervariasi, efek, atau kuas masuk sebagai tampilan gambarnya ({_1}): penampilannya belum dapat diedit +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding nama `{name}` bukan UTF-8 dan dibaca sebagai {label}: jika terlihat salah, buka file dengan legacyNameEncoding lain +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding nama `{name}` bukan UTF-8, dan sebagiannya juga bukan {label} yang valid: buka file dengan legacyNameEncoding lain +@msg more names that aren't UTF-8 were read as {label} ({count}) nama lain yang bukan UTF-8 dibaca sebagai {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradien “{name}” tidak didefinisikan dalam berkas: warna polos dipakai sebagai pengganti @msg an image VectorCraft can't read was left out gambar yang tidak dapat dibaca VectorCraft tidak disertakan @msg an image whose data is cut short was left out gambar yang datanya terpotong tidak disertakan +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers gambar tersemat berukuran {_1} × {_2} piksel diperkecil menjadi {_3} × {_4} piksel agar lapisannya tetap terjaga @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel gambar tertaut menampilkan pratinjau resolusi rendah yang tersimpan dalam berkas sampai berkasnya ditemukan: tautkan ulang di panel Tautan @msg linked type areas past VectorCraft's limits were left out area teks tertaut di luar batas VectorCraft dihilangkan @msg type that changes writing direction inside a text object opens in the direction of its first character teks yang mengubah arah penulisan di dalam objek teks dibuka dengan arah karakter pertamanya @@ -4684,6 +4749,7 @@ imageTrace Outline Kontur @msg object ids are non-negative integers, not {v} id objek adalah bilangan bulat non-negatif, bukan {v} @msg {key} must be an array of non-negative integer object ids {key} harus berupa larik id objek yang berupa bilangan bulat non-negatif @msg invalid object id {value}: expected a non-negative integer id objek {value} tidak valid: harus berupa bilangan bulat non-negatif +@msg `{key}` entry {index} is {value}, not a non-negative integer object id entri {index} dari `{key}` adalah {value}, bukan id objek (bilangan bulat non-negatif) @msg no such object id {value} tidak ada objek dengan id {value} @msg automation filesystem access is not granted: read authority is absent: {path} akses sistem berkas otomatisasi tidak diberikan: otoritas baca tidak ada: {path} @msg automation filesystem access is not granted: write authority is absent: {path} akses sistem berkas otomatisasi tidak diberikan: otoritas tulis tidak ada: {path} @@ -5725,6 +5791,8 @@ axis Y Axis Sumbu Y Install “{name}”, which asks for these permissions? Instal “{name}”, yang meminta izin berikut? ID: {id} ID: {id} Version: {version} Versi: {version} + Install “{name}” in place of the plug-in with the same ID? Instal “{name}” sebagai pengganti plug-in dengan ID yang sama? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. File ini bukan plug-in yang sebelumnya diinstal dengan ID ini. File ini mendapatkan pengaturan dan cache yang disimpan untuk ID tersebut. File SHA-256: {hash} SHA-256 file: {hash} Read the objects it is given Membaca objek yang diberikan kepadanya Change, add and delete objects in the document Mengubah, menambah, dan menghapus objek dalam dokumen @@ -5732,6 +5800,9 @@ axis Y Axis Sumbu Y Change how the objects its effect is applied to are drawn Mengubah cara objek yang diberi efeknya digambar Write the document as a file in its own format Menulis dokumen sebagai file dalam formatnya sendiri Show its progress, see Cancel and write log lines Menampilkan kemajuannya, mengenali pembatalan, dan menulis baris log + Keep settings of its own Menyimpan pengaturannya sendiri + Keep a cache of its own, which you can clear Menyimpan cache-nya sendiri, yang dapat Anda kosongkan + Clear Plug-in Cache Kosongkan cache plug-in # Actions panel: action sets (#3712). New Set… Set baru… New Set Set baru @@ -6113,6 +6184,8 @@ breakText Glyphs Glif File + Data Set Name File + nama set data Data Set Name Nama set data @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` harus `fileNumber`, `fileDataSetName`, atau `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Setiap set data disimpan menimpa file yang sama, sehingga hanya set data terakhir yang tersisa. +@msg save the document with Save As before Save and Close simpan dokumen dengan Simpan sebagai sebelum Simpan dan tutup @msg no free data set file name in destination folder Tidak ada nama file set data yang tersedia di folder tujuan # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6232,9 +6305,20 @@ breakText Glyphs Glif Reset Symbol Instance Atur ulang instans simbol @msg type must be static or dynamic, not {v} type harus static atau dynamic, bukan {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` adalah simbol statis: setel dulu jenis simbolnya ke Dinamis +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Hanya warna solid isian dan goresan jalur, jalur majemuk, atau teks yang dipilih dalam instans simbol yang dapat diubah @msg path must be a list of child indices path harus berupa daftar indeks anak @msg no object at path {shown} in the symbol's art tidak ada objek di jalur {shown} dalam karya seni simbol @msg give fill, stroke, opacity and/or blend berikan fill, stroke, opacity dan/atau blend +@msg {key} cannot be set on a guide {key} tidak dapat diatur pada panduan +@msg {key} cannot be set on a blend {key} tidak dapat diatur pada blend +@msg {key} cannot be set on an envelope {key} tidak dapat diatur pada amplop +@msg {key} cannot be set on a gradient mesh {key} tidak dapat diatur pada mesh gradien +@msg {key} cannot be set on a Repeat {key} tidak dapat diatur pada perulangan +@msg {key} cannot be set on a placed document {key} tidak dapat diatur pada dokumen yang ditempatkan +@msg {key} cannot be set on an Intertwine group {key} tidak dapat diatur pada grup jalinan +@msg stroke cannot be set on a symbol instance stroke tidak dapat diatur pada instans simbol +@msg fill on a symbol instance must be a solid color or none fill pada instans simbol harus berupa warna solid atau none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} tidak dapat diatur pada gambar tanpa efek Konversi ke bentuk atau Kerangka objek @msg pass newName, registration, exportType, nineSlice and/or type berikan newName, registration, exportType, nineSlice dan/atau type Text Flow Alur teks By Rows Per baris @@ -6264,6 +6348,7 @@ breakText Glyphs Glif Create Swatch Legend Buat Legenda Swatch Create Swatch Legend… Buat Legenda Swatch… HEX HEX + Help Bar Bilah Bantuan # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Alat Teks Text Tweaker… Penyetel Teks… @@ -6329,3 +6414,30 @@ 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 + +# Safe Mode + Safe Mode… Mode aman… + Safe Mode Mode aman + Help › Safe Mode… Bantuan › Mode aman… + Enable on Re-launch Aktifkan saat dijalankan berikutnya + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Peluncuran terakhir VectorCraft berhenti saat plug-in {name} sedang dimuat. Plug-in itu mungkin bukan penyebabnya. + Disable and Continue Nonaktifkan dan lanjutkan + Start Normally Mulai secara normal + Start in Safe Mode Mulai dalam mode aman + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft dalam mode aman: tidak ada plug-in yang dimuat atau dijalankan sampai aplikasi dimulai secara normal. + Disabled plug-ins Plug-in yang dinonaktifkan + No plug-ins are disabled. Tidak ada plug-in yang dinonaktifkan. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Mode aman aktif: plug-in tidak dimuat atau dijalankan sampai VectorCraft dimulai secara normal +@msg give `id` or `file` (see plugin.safeMode) berikan `id` atau `file` (lihat plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) plug-in itu tidak dinonaktifkan (lihat plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch Plug-in yang dinonaktifkan tidak dapat disimpan untuk peluncuran berikutnya +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Perluas tampilan: efek plug-in dalam seleksi tidak dapat diperluas dalam mode aman + +# Align to Pixel Grid + Align to Pixel Grid Sejajarkan ke grid piksel +@msg side is left or right, not `{s}` side adalah left atau right, bukan `{s}` diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index b19e01f96..2e197ede6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -198,6 +198,10 @@ Anti-Alias Anti-aliasing Anti-alias Rasters Anti-aliasing per immagini raster Anti-aliased Artwork Grafica con anti-aliasing + Anti-aliasing Method Metodo di anti-aliasing +antiAlias Sharp Nitido + Crisp Definito + Strong Forte Anti-aliasing: Anti-aliasing: Any Kind Qualsiasi tipo Font Technology Tecnologia font @@ -367,6 +371,13 @@ font-technology Variable Variabili Apply Bullets Applica punti elenco Numbered List Elenco numerato Apply Numbers Applica numerazione + Bullets and Numbering… Punti elenco e numerazione… + Numbers Numeri + Level: Livello: + Bullet Character: Carattere punto elenco: + Number Style: Stile numero: + Left Indent: Rientro sinistro: + First Line Indent: Rientro prima riga: Convert to Text Converti in testo Filled Dot Punto pieno Dash Trattino @@ -555,6 +566,11 @@ font-technology Variable Variabili Color Table ({n} colors) Tabella colori ({n} colori) Color Theme Tema colore Color Themes Temi colore + Color Wheel Ruota dei colori + Hue ring Anello tonalità + Saturation and brightness Saturazione e luminosità + Color field Campo colore + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Tipo di colore: Color: Colore: Colorize Colora @@ -1087,6 +1103,14 @@ font-technology Variable Variabili Export As SVG… Esporta come SVG… Export Asset Esporta risorsa Export Assets Esporta risorse + Export 3D Object Esporta oggetto 3D + 3D Export Format Formato di esportazione 3D + Export 3D Assets Esporta risorse 3D +@msg {what} can't be exported in 3D: {e} impossibile esportare {what} in 3D: {e} +@msg asset {id} isn't a 3D asset la risorsa {id} non è una risorsa 3D +@msg asset {id} is a 3D asset: assets.export3D writes it la risorsa {id} è una risorsa 3D: la scrive assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) ancora nessuna risorsa 3D: raccogli prima un oggetto con un effetto 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) seleziona un oggetto con un effetto 3D (o indicane gli ID) Export As… Esporta come… Export CSS Esporta CSS Export CSS… Esporta CSS… @@ -1118,6 +1142,17 @@ font-technology Variable Variabili Exporting {} Esportazione di {} in corso Export… Esporta… Extrude & Bevel… Estrusione e smusso… + 3D (Classic) 3D (classico) + Extrude & Bevel (Classic)… Estrusione e smusso (classico)… + Surface Superficie + Wireframe Reticolo + No Shading Nessuna ombreggiatura + Plastic Plastica + Highlight Intensity Intensità lucentezza + Highlight Size Dimensione lucentezza + Blend Steps Passaggi di fusione + Shading Color Colore ombreggiatura + Draw Hidden Faces Disegna facce nascoste Eyedropper Contagocce Sample Color from Screen Campiona colore dallo schermo This system has no screen color sampler Questo sistema non ha un campionatore di colore dello schermo @@ -1175,6 +1210,7 @@ font-technology Variable Variabili Stroke When Editable Traccia se modificabile Fill Color Colore riempimento Fill New Type Objects With Placeholder Text Riempi i nuovi oggetti di testo con testo segnaposto + Start Lists From Typed Markers Avvia elenchi dai marcatori digitati Fill Rule Regola di riempimento Fill with Placeholder Text Riempi con testo segnaposto Fill: Riempimento: @@ -1185,6 +1221,7 @@ font-technology Variable Variabili Find Font… Trova font… Find Next Trova successivo Find Objects Trova oggetti + Compare with Image Confronta con immagine First Page Prima pagina First-line Left Indent Rientro sinistro prima riga Fisheye… Occhio di pesce… @@ -1744,6 +1781,7 @@ file-filter Templates Modelli Line {line} Riga {line} Line {line}: {name}: {status} Riga {line}: {name}: {status} Imported. Press OK to keep the imported set. Importato. Premi OK per mantenere il set importato. + Read Action Set File Leggi file di set di azioni Read Shortcut Set File Leggi file di set di scelte rapide Import Matches Abbinamenti di importazione Remember Import Matches Ricorda abbinamenti di importazione @@ -1992,6 +2030,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 @@ -2796,6 +2838,7 @@ digits Malayalam Malayalam Reset Transformation Reimposta trasformazione Replace With Font Sostituisci con font Replace With Font From Sostituisci con font da + Find In Cerca in System Sistema Replaces the saved preset of that name. Sostituisce il preset salvato con quel nome. Replace… Sostituisci… @@ -3096,6 +3139,11 @@ digits Malayalam Malayalam Selected Swatches Campioni selezionati Selected Swatches Only Solo campioni selezionati Selection Selezione +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Selezione rettangolare + Touching Oggetti toccati + Fully Enclosed Interamente inclusi + Drag Direction Direzione di trascinamento Selection & Anchor Display Visualizzazione selezione e punti di ancoraggio Selection Only Solo selezione Selection Tool Strumento Selezione @@ -3233,6 +3281,7 @@ digits Malayalam Malayalam Simulate Colored Paper Simula carta colorata Simulate Paper Simula carta Double Column Colonna doppia + Keep Tools Beside the Canvas Strumenti accanto all'area di disegno Single Left Quotation Mark Virgoletta singola di apertura Single Quotes: Virgolette singole: Single Right Quotation Mark Virgoletta singola di chiusura @@ -3481,6 +3530,15 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Impostazioni Tate-chu-yoko… Tate-chu-yoko Settings Impostazioni Tate-chu-yoko + Composite Fonts… Font compositi… + Composite Fonts Font compositi + Export Composite Fonts Esporta font compositi + Import Composite Fonts Importa font compositi + Delete Font Elimina font + Kanji Kanji + Kana Kana + Numerals Numeri + Scale at Center Ridimensiona dal centro Up/Down: Su/giù: Left/Right: Sinistra/destra: Temperature Temperatura @@ -3534,6 +3592,8 @@ digits Malayalam Malayalam Tile Type: Tipo di tassello: tile {number} riquadro {number} Tiles are numbered across, then down; leave the range empty for every tile. I riquadri sono numerati da sinistra a destra, poi dall'alto in basso; lascia vuoto l'intervallo per stamparli tutti. + Copies per Sheet (as many as fit, in a grid) Copie per foglio (quante ne entrano, in griglia) + Gutter: Spazio intermedio: Tint Tinta Tip extends past the end point La punta si estende oltre il punto finale Tip on the end point Punta sul punto finale @@ -3829,6 +3889,7 @@ digits Malayalam Malayalam Zoom to Selection Zoom sulla selezione Zoom Tool Strumento Zoom Zoom with Mouse Wheel Zoom con la rotellina del mouse + Scroll Sideways with Cmd/Ctrl + Wheel Scorrimento orizzontale con Cmd/Ctrl + rotellina Scrub Numeric Fields by Dragging Regola i campi numerici trascinando @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} file in {folder} {note}. Vuoi mostrare la cartella del pacchetto?|{n} file in {folder} {note}. Vuoi mostrare la cartella del pacchetto? @plural {n} linked file not found|{n} linked files not found File collegati non trovati: {n}|File collegati non trovati: {n} @@ -4003,7 +4064,7 @@ axis Both Entrambi Dock to Bottom Ancora in basso Dock Control Bar Ancora barra di controllo Drag to float the toolbar Trascina per rendere mobile la barra degli strumenti - Drag to the window's left edge to dock Trascina sul bordo sinistro della finestra per ancorare + Drag to the window's left or right edge to dock Trascina sul bordo sinistro o destro della finestra per ancorare Float Tool Group Rendi mobile gruppo strumenti # Status and error messages (`@msg`), translated where the status bar shows them; the message itself @@ -4012,7 +4073,7 @@ axis Both Entrambi # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` nessuna libreria `{id}` @msg select some type first seleziona prima del testo -@msg kerning must be a number, "auto", or "optical" La crenatura deve essere un numero, «auto» o «optical» +@msg kerning must be a number, "auto", or "optical" kerning deve essere un numero, «auto» o «optical» @msg the selection has no gradient fill la selezione non ha una sfumatura di riempimento @msg the selection has no gradient stroke la selezione non ha una sfumatura di traccia @msg the selection has no pattern fill la selezione non ha un motivo di riempimento @@ -4116,6 +4177,7 @@ axis Both Entrambi @msg Targa files are RGB: colorModel rgb I file Targa sono RGB: colorModel rgb @msg Text Wrap: select a wrap object Testo attorno: seleziona un oggetto di avvolgimento @msg This shortcut file is in a form VectorCraft can't read. Questo file di scelte rapide è in un formato che VectorCraft non può leggere. +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` deve essere "none", "sharp", "crisp" o "strong" @msg `aspect` must be a number (%) `aspect` deve essere un numero (%) @msg `at` must be [x, y] `at` deve essere [x, y] @msg `color` must be a colour `color` deve essere un colore @@ -4239,9 +4301,9 @@ axis Both Entrambi @msg bad shortcut scelta rapida non valida @msg bad stream length lunghezza dello stream non valida @msg bad trailer trailer non valido -@msg balance must be between 0 and 100 il bilanciamento deve essere compreso tra 0 e 100 +@msg balance must be between 0 and 100 balance deve essere compreso tra 0 e 100 @msg batch step {_1} (`{id}`) failed: {e} il passaggio {_1} del batch (`{id}`) non è riuscito: {e} -@msg brightness must be dark|mediumDark|mediumLight|light la luminosità deve essere dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light brightness deve essere dark|mediumDark|mediumLight|light @msg can't encode the document: {0} impossibile codificare il documento: {0} @msg can't parse shortcut `{c}` impossibile interpretare la scelta rapida `{c}` @msg can't parse shortcut `{s}` for `{k}` impossibile interpretare la scelta rapida `{s}` per `{k}` @@ -4266,7 +4328,7 @@ axis Both Entrambi @msg compound paths can only contain paths i tracciati composti possono contenere solo tracciati @msg compressed (RLE) bitmaps can't be read yet le bitmap compresse (RLE) non possono ancora essere lette @msg damaged danneggiato -@msg decimals must be {_1}–{_2} (got {_3}) i decimali devono essere {_1}–{_2} (ricevuto {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals deve essere {_1}–{_2} (ricevuto {_3}) @msg divide into 1 to {MAX_PIECES} slices dividi in un numero di sezioni da 1 a {MAX_PIECES} @msg effect {k} is `{_1}`, not `{id}` l'effetto {k} è `{_1}`, non `{id}` @msg empty brush stroke traccia di pennello vuota @@ -4391,6 +4453,8 @@ axis Both Entrambi @msg no such action set set di azioni inesistente @msg the action sets changed; nothing was deleted i set di azioni sono cambiati; non è stato eliminato nulla @msg no action set to save nessun set di azioni da salvare +@msg the action set file can't be read in full il file di set di azioni non può essere letto per intero +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Caricato “{name}”: {n} passaggio è stato omesso|Caricato “{name}”: {n} passaggi sono stati omessi @msg not an action set file non è un file di set di azioni @msg the action set file is larger than 8 MiB il file del set di azioni supera 8 MiB @msg the action set file holds more than 1,000 actions il file del set di azioni contiene più di 1.000 azioni @@ -4465,7 +4529,7 @@ axis Both Entrambi @msg replace: the object to replace was changed or deleted replace: l’oggetto da sostituire è stato modificato o eliminato @msg pattern missing motivo mancante @msg patterns nested too deeply are filled with mid-grey i motivi annidati troppo in profondità vengono riempiti di grigio medio -@msg percent must be above 0 and at most 6400, not {p} la percentuale deve essere maggiore di 0 e al massimo 6400, non {p} +@msg percent must be above 0 and at most 6400, not {p} percent deve essere maggiore di 0 e al massimo 6400, non {p} @msg pick a colour, gradient or pattern to save as a swatch scegli un colore, una sfumatura o un motivo da salvare come campione @msg plane must be left, right or ground plane deve essere left, right o ground @msg plug-in ABI: {0} ABI del plug-in: {0} @@ -4486,8 +4550,8 @@ axis Both Entrambi @msg result would exceed the canvas (coordinates out of range) il risultato supererebbe l'area di disegno (coordinate fuori intervallo) @msg objects can't be nested more than 32 deep gli oggetti non possono essere nidificati oltre 32 livelli @msg the SVG's elements are nested more than {0} deep gli elementi dell'SVG sono nidificati oltre {0} livelli -@msg scale must be a number from {_1} to {_2}, not {_3} la scala deve essere un numero da {_1} a {_2}, non {_3} -@msg scale must be a number from {_1} to {_2}, not {s} la scala deve essere un numero da {_1} a {_2}, non {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale deve essere un numero da {_1} a {_2}, non {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale deve essere un numero da {_1} a {_2}, non {s} @msg select (or target) objects whose appearance isn't basic seleziona (o imposta come destinazione) oggetti con un aspetto non di base @msg select a Live Paint group seleziona un gruppo di pittura dinamica @msg select a blend seleziona una fusione @@ -4515,6 +4579,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 @@ -4752,11 +4823,15 @@ axis Both Entrambi @msg its objects are nested too deeply i loro oggetti sono annidati troppo in profondità @msg its section ending with `%{end}` doesn't end la loro sezione che termina con `%{end}` non termina @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) i pennelli che VectorCraft non sa ancora leggere sono stati esclusi dal pannello Pennelli ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet gli oggetti con più riempimenti o tratti, effetti o pennelli sono stati importati come appaiono disegnati ({_1}): il loro aspetto non è ancora modificabile +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet gli oggetti con più riempimenti o tratti, tratti a larghezza variabile, effetti o pennelli sono stati importati come appaiono disegnati ({_1}): il loro aspetto non è ancora modificabile +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding il nome `{name}` non è in UTF-8 ed è stato letto come {label}: se sembra errato, apri il file con un altro legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding il nome `{name}` non è in UTF-8 e alcune sue parti non sono valide nemmeno in {label}: apri il file con un altro legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) altri nomi non in UTF-8 sono stati letti come {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it la sfumatura “{name}” non è definita nel file: la sostituisce un colore pieno @msg an image VectorCraft can't read was left out un'immagine che VectorCraft non può leggere è stata omessa @msg graphs come in as plain art, without their data or settings i grafici vengono importati come grafica semplice, senza i loro dati e le loro impostazioni @msg an image whose data is cut short was left out un'immagine con dati incompleti è stata omessa +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers un'immagine incorporata di {_1} × {_2} pixel è stata ridotta a {_3} × {_4} pixel per mantenere i livelli @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel le immagini collegate mostrano l'anteprima a bassa risoluzione salvata nel file finché i loro file non vengono trovati: ricollegale nel pannello Collegamenti @msg linked type areas past VectorCraft's limits were left out le aree di testo concatenate oltre i limiti di VectorCraft sono state omesse @msg type that changes writing direction inside a text object opens in the direction of its first character il testo che cambia direzione di scrittura all’interno di un oggetto di testo si apre nella direzione del suo primo carattere @@ -4818,6 +4893,7 @@ axis Both Entrambi @msg object ids are non-negative integers, not {v} gli ID oggetto sono interi non negativi, non {v} @msg {key} must be an array of non-negative integer object ids {key} deve essere un array di ID oggetto interi non negativi @msg invalid object id {value}: expected a non-negative integer ID oggetto {value} non valido: è richiesto un intero non negativo +@msg `{key}` entry {index} is {value}, not a non-negative integer object id la voce {index} di `{key}` è {value}, non un ID oggetto (intero non negativo) @msg no such object id {value} nessun oggetto con ID {value} @msg id {value} is a layer, not an object l’ID {value} è un livello, non un oggetto @msg id {value} is a layer, not an object; move layers with layer.move l’ID {value} è un livello, non un oggetto; sposta i livelli con layer.move @@ -4844,12 +4920,19 @@ axis Both Entrambi @msg the Pixar file's image data is damaged i dati immagine del file Pixar sono danneggiati @msg the JPEG 2000 file can't be read impossibile leggere il file JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content il file JPEG 2000 contiene uno spazio colore o un contenuto JPX non supportato +@msg the HEIC or HEIF file can't be read impossibile leggere il file HEIC o HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read il file HEIC o HEIF contiene un tipo di immagine che non può essere letto +@msg HEIC and HEIF images can't be opened in this build le immagini HEIC e HEIF non possono essere aperte in questa build @msg the PSD image is too large to read ({_1} × {_2} pixels) l'immagine PSD è troppo grande per essere letta ({_1} × {_2} pixel) @msg zipped 32-bit PSD image data with prediction can't be read i dati immagine PSD a 32 bit compressi ZIP con previsione non possono essere letti @msg {depth}-bit PSD files in this colour mode can't be read i file PSD a {depth} bit in questo metodo colore non possono essere letti @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 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 De-Interlace… Deinterlaccia… De-Interlace Deinterlaccia @@ -4907,6 +4990,9 @@ effect Soft Morbido effect Regular Normale Edge Bordo System Title Bar Barra del titolo di sistema + VectorCraft Title Bar on Every Desktop Barra del titolo di VectorCraft su ogni desktop + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Finché è impostata, la variabile d'ambiente VECTORCRAFT_TITLEBAR sceglie la barra del titolo. + System Title Bar takes precedence over this option while it is on. Finché è attiva, Barra del titolo di sistema ha la precedenza su questa opzione. Value Axis Asse dei valori On Left Side Sul lato sinistro On Right Side Sul lato destro @@ -4953,7 +5039,7 @@ effect Regular Normale @msg before must name a panel before deve indicare un pannello @msg docking action contains an unknown panel or an excessive split path L’azione di ancoraggio contiene un pannello sconosciuto o un percorso di divisioni troppo lungo @msg group must be true or false group deve essere true o false -@msg panel is not in a dock group il pannello non è in un gruppo del dock +@msg panel is not in a dock group panel non è in un gruppo del dock @msg only a dock group above or below others collapses, not one beside another si comprime solo un gruppo del dock sopra o sotto altri, non uno accanto @msg icon must be true or false icon deve essere true o false @msg icon only docks a panel in the icon column, with no anchor icon ancora un pannello solo nella colonna delle icone, senza anchor @@ -5120,6 +5206,11 @@ lighting Light Luce @msg unknown parameter `{key}`; the command takes: {keys} parametro sconosciuto `{key}`; il comando accetta: {keys} Load EPS into Clipboard Carica EPS negli appunti @msg an image of {w} × {h} pixels is too large to draw un'immagine di {w} × {h} pixel è troppo grande per essere disegnata +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: non è un'immagine PNG, JPEG, WebP, TIFF o BMP +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ l'immagine è di {iw} × {ih} pixel e la tavola da disegno a quella larghezza è di {rw} × {rh} pixel: le proporzioni sono diverse +@msg `threshold` must be a number from 0 to 1 `threshold` deve essere un numero da 0 a 1 +@msg background {v}: a color such as "#808080" sfondo {v}: un colore come "#808080" +@msg comparing with an image file isn't available in the browser il confronto con un file immagine non è disponibile nel browser @msg an image of {w} × {h} pixels is shown at reduced size un'immagine di {w} × {h} pixel viene mostrata a dimensioni ridotte @msg the image can't be read as a texture l'immagine non può essere letta come texture Basic Base @@ -5318,11 +5409,19 @@ lighting Light Luce @msg too many library panels were opened in this session in questa sessione sono stati aperti troppi pannelli libreria @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. L’immagine è di {_1} × {_2} pixel, più di {LARGE_TRACE_SIDE} su un lato: viene vettorializzata, ma più lentamente. Un predefinito a fedeltà più bassa o una copia più piccola dell’immagine è più veloce. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). L’immagine è stata vettorializzata a {_1} × {_2} pixel, ridotta da {_3} × {_4} (al massimo {_5} megapixel). +@msg byColor must be true or false byColor deve essere true o false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor non può dividere i gruppi di Raggruppamento automatico: vettorializza senza Raggruppamento automatico +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor non può raggruppare di nuovo una traccia con un effetto Pathfinder attivo: espandi senza byColor Snapping Aggancio Snap to Tangent Aggancia alla tangente Snap to Perpendicular Aggancia alla perpendicolare @msg unknown snap option `{key}` (use {options}) opzione di aggancio sconosciuta `{key}` (usare {options}) Gap Length: Lunghezza lacuna: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. La modalità Logo piatto non usa keepColors: vettorializza i colori indicati in logoColors o i colori che trova. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Non è stato vettorializzato nulla: nessuno dei colori trovati dalla vettorializzazione corrisponde a keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Non è stato vettorializzato nulla: Ignora colore esclude ogni area dei colori elencati in keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Non è stato vettorializzato nulla: i colori che corrispondono a keepColors non producono tracciati. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: nessuno dei colori trovati dalla vettorializzazione corrisponde a {c}, quindi non ne viene vettorializzato nulla. @msg `colorGroup` needs `library: "document"` `colorGroup` richiede `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` richiede `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` richiede `mode: "color"` @@ -5437,11 +5536,14 @@ axis Y Axis Asse Y @msg Running “{name}”… Esecuzione di “{name}”… @msg a live effect runs while the document is drawn, so it can't be `heavy` un effetto dinamico viene eseguito mentre il documento viene disegnato, quindi non può essere `heavy` @msg an exporter always gets an export's budget: leave `budget` out un plug-in di esportazione riceve sempre il budget di un’esportazione: ometti `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” chiede un budget di calcolo elevato (ogni esecuzione fino a {minutes} minuti e {times} volte le istruzioni consuete): installalo con `allowHeavy: true` quando l’utente acconsente +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” chiede un budget di calcolo elevato (ogni esecuzione fino a {minutes} minuti e {times} volte le istruzioni consuete): una persona deve approvarlo nell’app desktop, oppure passa `allowHeavy: true` in una sessione avviata con --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Installare “{name}”, che chiede queste autorizzazioni? ID: {id} ID: {id} Version: {version} Versione: {version} + Install “{name}” in place of the plug-in with the same ID? Installare “{name}” al posto del plug-in con lo stesso ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Questo file non è il plug-in installato prima con questo ID. Riceve le impostazioni e la cache conservate per quell’ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” è un modulo diverso dal plug-in con ID {_1} e riceverebbe le impostazioni e la cache conservate per esso: installalo con `replace: true` quando l’utente acconsente File SHA-256: {hash} SHA-256 del file: {hash} Read the objects it is given Leggere gli oggetti che riceve Change, add and delete objects in the document Modificare, aggiungere ed eliminare oggetti nel documento @@ -5449,21 +5551,43 @@ 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 Show its progress, see Cancel and write log lines Mostrare il proprio avanzamento, rilevare l’annullamento e scrivere righe di registro -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorizzazione `progress` richiede il livello API dei plug-in {PROGRESS_API}: indicare `api` {PROGRESS_API} + Keep settings of its own Conservare impostazioni proprie + Keep a cache of its own, which you can clear Conservare una cache propria, che è possibile svuotare + Clear Plug-in Cache Svuota cache del plug-in +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorizzazione `{permission}` richiede il livello API dei plug-in {PROGRESS_API}: indicare `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} il modulo importa `env.{name}` con un tipo diverso da quello dell’host: quello dell’host è {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` il modulo importa `env.{name}`, che un plug-in può usare solo al livello API dei plug-in {PROGRESS_API} (`api` {PROGRESS_API}) e con l’autorizzazione `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network il modulo importa `{from}.{name}`, che l’host non offre: un plug-in può importare solo `env.vc_progress`, `env.vc_cancelled` e `env.vc_log` (livello API dei plug-in 2), e nessun WASI, file o rete +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` il modulo importa `env.{name}`, che un plug-in può usare solo al livello API dei plug-in {PROGRESS_API} (`api` {PROGRESS_API}) e con l’autorizzazione `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network il modulo importa `{from}.{name}`, che l’host non offre: un plug-in può importare solo le funzioni `env.vc_*` elencate in docs/plugins.md (livello API dei plug-in 2), e nessun WASI, file o rete @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` may only be true `paintOutput` può essere solo true +@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}` @msg at most {MAX_PERMISSIONS} `permissions` al massimo {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` l’autorizzazione `{shown}` non si applica a un plug-in di tipo `{_1}` @msg permission `{shown}` is listed twice l’autorizzazione `{shown}` è elencata due volte +@msg only a filter declares `inputs` solo un filtro dichiara `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` indica un tipo di oggetto sconosciuto `{shown}`: usa type, image o symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` elenca `{shown}` due volte +@msg `inputs` needs `api` 2 or later `inputs` richiede `api` 2 o successivo +@msg `inputs` needs the permission `doc.read` `inputs` richiede l’autorizzazione `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) budget di memoria (l’oggetto {_1} richiede {size} byte di input, ne restano {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) budget di oggetti (più di {MAX_OBJECTS} oggetti di sola lettura) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorizzazione `{shown}` è riservata a un’API dei plug-in successiva; questo host non la concede @msg unknown permission `{shown}` autorizzazione sconosciuta `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” chiede le autorizzazioni {asked}: installalo con un `grant` che le elenca quando l’utente acconsente +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” chiede le autorizzazioni {asked}: una persona deve approvarle nell’app desktop, oppure passa `grant` in una sessione avviata con --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` e `allowHeavy` hanno effetto solo in una sessione avviata con --allow-plugin-grants: approva il plug-in nell’app desktop +@msg Only the person at the keyboard can approve installing a plug-in Solo la persona alla tastiera può approvare l’installazione di un plug-in @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a “{name}” non è stata concessa `{p}`, che serve al suo risultato: non è stato modificato nulla +@msg a heavy filter runs in the background, so it can't declare `commands` un filtro pesante viene eseguito in background, quindi non può dichiarare `commands` +@msg at most {MAX_COMMANDS} `commands` al massimo {MAX_COMMANDS} `commands` +@msg command {name} is declared twice il comando {name} è dichiarato due volte +@msg command {name}: `label` must be 1-64 printable characters comando {name}: `label` deve avere 1-64 caratteri stampabili +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter comando {name}: usa un id di 1-{MAX_COMMAND_ID} caratteri tra a-z 0-9 _ che inizi con una lettera +@msg command {name}: {e} comando {name}: {e} +@msg only a filter or a live effect declares `commands` solo un filtro o un effetto dinamico dichiara `commands` +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed il modulo non ha lo SHA-256 indicato da `hash`: non è stato installato nulla # Actions panel: action sets (#3712). New Set… Nuovo set… @@ -5884,6 +6008,8 @@ breakText Glyphs Glifi File + Data Set Name File + nome set di dati Data Set Name Nome set di dati @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` deve essere `fileNumber`, `fileDataSetName` o `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Ogni set di dati viene salvato sullo stesso file, che conserva solo l'ultimo set di dati. +@msg save the document with Save As before Save and Close salva il documento con «Salva con nome» prima di «Salva e chiudi» @msg no free data set file name in destination folder Nessun nome file libero per il set di dati nella cartella di destinazione # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6004,9 +6130,20 @@ breakText Glyphs Glifi Reset Symbol Instance Reimposta istanza di simbolo @msg type must be static or dynamic, not {v} type deve essere static o dynamic, non {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` è un simbolo statico: imposta prima il tipo di simbolo su Dinamico +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Di un tracciato, un tracciato composto o un testo selezionato in un'istanza di simbolo si può modificare solo il colore pieno di riempimento e traccia @msg path must be a list of child indices path deve essere un elenco di indici figlio @msg no object at path {shown} in the symbol's art nessun oggetto al percorso {shown} nella grafica del simbolo @msg give fill, stroke, opacity and/or blend specifica fill, stroke, opacity e/o blend +@msg {key} cannot be set on a guide {key} non può essere impostato su una guida +@msg {key} cannot be set on a blend {key} non può essere impostato su una fusione +@msg {key} cannot be set on an envelope {key} non può essere impostato su un involucro +@msg {key} cannot be set on a gradient mesh {key} non può essere impostato su una trama sfumatura +@msg {key} cannot be set on a Repeat {key} non può essere impostato su una ripetizione +@msg {key} cannot be set on a placed document {key} non può essere impostato su un documento inserito +@msg {key} cannot be set on an Intertwine group {key} non può essere impostato su un gruppo intrecciato +@msg stroke cannot be set on a symbol instance stroke non può essere impostato su un’istanza di simbolo +@msg fill on a symbol instance must be a solid color or none il fill di un’istanza di simbolo deve essere un colore pieno o none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} non può essere impostato su un’immagine senza l’effetto Converti in forma o Contorna oggetto @msg pass newName, registration, exportType, nineSlice and/or type specifica newName, registration, exportType, nineSlice e/o type Text Flow Flusso del testo By Rows Per righe @@ -6036,6 +6173,7 @@ breakText Glyphs Glifi Create Swatch Legend Crea legenda campioni Create Swatch Legend… Crea legenda campioni… HEX HEX + Help Bar Barra della guida # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Strumenti testo Text Tweaker… Regolatore testo… @@ -6101,3 +6239,44 @@ 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 plug-in of kind `{_2}`, not an importer `{_1}` è un plug-in di tipo `{_2}`, non un plug-in di importazione +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` è un plug-in di tipo `{_2}`, non un plug-in di esportazione +@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` + +# Safe Mode + Safe Mode… Modalità provvisoria… + Safe Mode Modalità provvisoria + Help › Safe Mode… Aiuto › Modalità provvisoria… + Enable on Re-launch Attiva al prossimo avvio + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. L'ultimo avvio di VectorCraft si è interrotto durante il caricamento del plug-in {name}. Potrebbe non esserne la causa. + Disable and Continue Disattiva e continua + Start Normally Avvia normalmente + Start in Safe Mode Avvia in modalità provvisoria + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft è in modalità provvisoria: nessun plug-in viene caricato o eseguito finché non si avvia normalmente. + Disabled plug-ins Plug-in disattivati + No plug-ins are disabled. Nessun plug-in disattivato. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally La modalità provvisoria è attiva: i plug-in non vengono caricati né eseguiti finché VectorCraft non si avvia normalmente +@msg give `id` or `file` (see plugin.safeMode) indica `id` o `file` (vedi plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) quel plug-in non è disattivato (vedi plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch Impossibile salvare i plug-in disattivati per il prossimo avvio +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Espandi aspetto: un effetto plug-in nella selezione non può essere espanso in modalità provvisoria + +# Align to Pixel Grid + Align to Pixel Grid Allinea alla griglia pixel +@msg only paths and compound paths can be aligned to the pixel grid solo i tracciati e i tracciati composti possono essere allineati alla griglia pixel +@msg side is left or right, not `{s}` side è left o right, non `{s}` diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 77d50e0a8..98c1383c2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -76,6 +76,10 @@ digits Malayalam マラヤーラム数字 Stylistic スタイリスティック Mojikumi Set 文字組み Kinsoku Set 禁則処理 + Kinsoku Shori Type 禁則調整方式 + Push In First 追い込み優先 + Push Out First 追い出し優先 + Push Out Only 追い出しのみ Hard 強い禁則 Soft 弱い禁則 Latin 欧文 @@ -154,6 +158,7 @@ digits Malayalam マラヤーラム数字 Find and Replace… 検索と置換… Find Next 次を検索 Find Objects オブジェクトを検索 + Compare with Image 画像と比較 Spelling スペルチェック Auto Spell Check 自動スペルチェック Check Spelling… スペルチェック… @@ -212,6 +217,11 @@ digits Malayalam マラヤーラム数字 Ungroup グループ解除 Lock ロック Selection 選択 +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection 矩形選択 + Touching 接触するオブジェクト + Fully Enclosed 完全に囲まれたオブジェクト + Drag Direction ドラッグ方向 All Artwork Above 前面のすべてのアートワーク Other Layers その他のレイヤー Unlock All すべてをロック解除 @@ -349,6 +359,13 @@ digits Malayalam マラヤーラム数字 Apply Bullets 箇条書きを適用 Numbered List 番号付きリスト Apply Numbers 番号付けを適用 + Bullets and Numbering… 箇条書きと番号付け… + Numbers 番号 + Level: レベル: + Bullet Character: 箇条書き記号: + Number Style: 番号の形式: + Left Indent: 左インデント: + First Line Indent: 1 行目インデント: Convert to Text テキストに変換 Filled Dot 黒丸 Dash ダッシュ @@ -493,6 +510,17 @@ digits Malayalam マラヤーラム数字 Inflate 膨張 Select objects, then pick an object type to give them a 3D effect. オブジェクトを選択してから、オブジェクトの種類を選んで 3D 効果を適用してください。 Extrude & Bevel… 押し出しとベベル… + 3D (Classic) 3D(クラシック) + Extrude & Bevel (Classic)… 押し出しとベベル(クラシック)… + Surface 表面 + Wireframe ワイヤーフレーム + No Shading 陰影なし + Plastic プラスチック + Highlight Intensity ハイライトの強さ + Highlight Size ハイライトのサイズ + Blend Steps ブレンドの階調 + Shading Color 陰影のカラー + Draw Hidden Faces 隠れた面を描画 Revolve… 回転体… Inflate… 膨張… Materials… マテリアル… @@ -678,6 +706,7 @@ digits Malayalam マラヤーラム数字 Toolbars ツールバー Advanced 詳細 Double Column 2 列 + Keep Tools Beside the Canvas ツールをキャンバスの横に配置 Actions アクション Asset Export アセットの書き出し Attributes 属性 @@ -685,6 +714,11 @@ digits Malayalam マラヤーラム数字 Color カラー Color Guide カラーガイド Color Themes カラーテーマ + Color Wheel カラーホイール + Hue ring 色相リング + Saturation and brightness 彩度と明るさ + Color field カラーフィールド + {name1} {value1}, {name2} {value2} {name1} {value1}、{name2} {value2} CSS Properties CSS プロパティ Document Info ドキュメント情報 Flattener Preview 分割・統合プレビュー @@ -979,6 +1013,10 @@ digits Malayalam マラヤーラム数字 Anti-alias アンチエイリアス Anti-alias Rasters ラスターをアンチエイリアス Anti-aliased Artwork アートワークをアンチエイリアス + Anti-aliasing Method アンチエイリアスの方式 +antiAlias Sharp シャープ + Crisp くっきり + Strong 太め Anti-aliasing: アンチエイリアス: Appearance Item アピアランス項目 Appearance Panel アピアランスパネル @@ -1582,6 +1620,14 @@ digits Malayalam マラヤーラム数字 PDF Print 印刷用 PDF Export As 形式を指定して書き出し Export Assets アセットを書き出し + Export 3D Object 3D オブジェクトを書き出し + 3D Export Format 3D 書き出し形式 + Export 3D Assets 3D アセットを書き出し +@msg {what} can't be exported in 3D: {e} {what} は 3D で書き出せません: {e} +@msg asset {id} isn't a 3D asset アセット {id} は 3D アセットではありません +@msg asset {id} is a 3D asset: assets.export3D writes it アセット {id} は 3D アセットです: assets.export3D で書き出します +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) 3D アセットがまだありません: まず 3D 効果のあるオブジェクトを追加してください (assets.add3D) +@msg select an object with a 3D effect (or give its ids) 3D 効果のあるオブジェクトを選択してください (または ID を指定) Export CSS CSS を書き出し Export CSS… CSS を書き出し… Export DXF DXF を書き出し @@ -1634,6 +1680,7 @@ digits Malayalam マラヤーラム数字 Files of this standard tag their colours with ICC profiles. この規格のファイルでは、カラーに ICC プロファイルが付けられます。 Fill 塗り Fill New Type Objects With Placeholder Text 新規テキストオブジェクトをダミーテキストで埋める + Start Lists From Typed Markers 入力した記号からリストを開始 Fill Rule 塗りのルール Fill and Stroke 塗りと線 Fill new strokes 新しいストロークに塗りを適用 @@ -2000,6 +2047,7 @@ digits Malayalam マラヤーラム数字 Line {line} {line} 行目 Line {line}: {name}: {status} {line} 行目: {name}: {status} Imported. Press OK to keep the imported set. 読み込みました。読み込んだセットを保持するには OK を押してください。 + Read Action Set File アクションセットファイルを読む Read Shortcut Set File ショートカットセットファイルを読む Import Matches 読み込み時の対応付け Remember Import Matches 読み込み時の対応付けを記憶 @@ -2776,6 +2824,7 @@ mark-style Japanese 日本式 Reset Transformation 変形をリセット Replace With Font 置換するフォント Replace With Font From 置換するフォントの一覧 + Find In 検索範囲 System システム Replace it with another file, or ignore it: its images keep showing the preview saved with the document. 別のファイルに置き換えるか、無視してください。無視した場合、画像にはドキュメントに保存されたプレビューが表示され続けます。 Replaces the saved preset of that name. 同じ名前の保存済みプリセットを置き換えます。 @@ -3235,6 +3284,8 @@ mark-style Japanese 日本式 Tile Range: 分割範囲: Tile Type: タイルの種類: Tiles are numbered across, then down; leave the range empty for every tile. タイルは左から右、上から下の順に番号が付きます。範囲を空にするとすべてのタイルが対象です。 + Copies per Sheet (as many as fit, in a grid) 用紙ごとに複数コピー (収まるだけグリッド状に配置) + Gutter: 間隔: Toggle Fill/Stroke Focus 塗り/線の切り替え Toggle Selection 選択を切り替え Toggle single/double column 1 列/2 列を切り替え @@ -3407,6 +3458,7 @@ mark-style Japanese 日本式 Zoom Tool ズームツール Zoom to Selection 選択範囲にズーム Zoom with Mouse Wheel マウスホイールでズーム + Scroll Sideways with Cmd/Ctrl + Wheel Cmd/Ctrl + ホイールで横スクロール Scrub Numeric Fields by Dragging ドラッグで数値フィールドを調整 [Default] is protected: New… starts an editable copy. [Default] は変更できません。「新規…」で編集できる複製を作ります。 [Unnamed] [名称未設定] @@ -3804,6 +3856,15 @@ file-filter Templates テンプレート Tate-chu-yoko 縦中横 Tate-chu-yoko Settings… 縦中横の設定… Tate-chu-yoko Settings 縦中横の設定 + Composite Fonts… 合成フォント… + Composite Fonts 合成フォント + Export Composite Fonts 合成フォントを書き出し + Import Composite Fonts 合成フォントを読み込み + Delete Font フォントを削除 + Kanji 漢字 + Kana かな + Numerals 数字 + Scale at Center 中心から拡大・縮小 Up/Down: 上下: Left/Right: 左右: Temperature 色温度 @@ -3945,7 +4006,7 @@ axis Both 両方 Dock to Bottom 下部にドッキング Dock Control Bar コントロールバーをドッキング Drag to float the toolbar ドラッグしてツールバーをフローティング - Drag to the window's left edge to dock ウィンドウの左端までドラッグしてドッキング + Drag to the window's left or right edge to dock ウィンドウの左端または右端までドラッグしてドッキング Float Tool Group ツールグループをフローティング # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. @@ -4106,14 +4167,14 @@ swatch Bright Violet ビビッドバイオレット @msg The last start of VectorCraft ended on {name} before its window appeared, so it started on {summary}. To try that graphics processor again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set 前回の VectorCraft の起動はウィンドウが表示される前に {name} で終了したため、{summary} で起動しました。このグラフィックプロセッサーをもう一度試すには、「環境設定 › パフォーマンス › グラフィックプロセッサー」を変更するか、WGPU_BACKEND を設定して起動してください @msg The last start of VectorCraft ended while it listed the graphics processors, so it started on {summary} with OpenGL alone. To try the other graphics backends again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set 前回の VectorCraft の起動はグラフィックプロセッサーの一覧を取得している間に終了したため、OpenGL のみを使って {summary} で起動しました。ほかのグラフィックバックエンドをもう一度試すには、「環境設定 › パフォーマンス › グラフィックプロセッサー」を変更するか、WGPU_BACKEND を設定して起動してください @msg not a DXF drawing DXF 図面ではありません -@msg scale must be a number from {_1} to {_2}, not {s} 縮尺は {_1} から {_2} までの数値にしてください ({s} は指定できません) +@msg scale must be a number from {_1} to {_2}, not {s} scale は {_1} から {_2} までの数値にしてください ({s} は指定できません) @msg the artboard to fit must be a positive size up to {_1} pt 合わせる先のアートボードのサイズは 0 より大きく {_1} pt 以下にしてください @msg the drawing has no layout `{_1}` (it has {_2}) 図面にレイアウト `{_1}` はありません (含まれるレイアウト: {_2}) @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that DWG 図面は読み込めません: CAD アプリで ASCII DXF として保存し、そのファイルを開いてください @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard この縮尺では図面が {w} × {h} pt になり、カンバスに収まりません: 縮尺を小さくするか、アートボードに合わせてください @msg the scale is not a usable number 縮尺が有効な数値ではありません @msg the drawing is too large to open 図面が大きすぎて開けません -@msg scale must be a number from {_1} to {_2}, not {_3} 縮尺は {_1} から {_2} までの数値にしてください ({_3} は指定できません) +@msg scale must be a number from {_1} to {_2}, not {_3} scale は {_1} から {_2} までの数値にしてください ({_3} は指定できません) @msg an image has no pixels and was left out ピクセルがない画像を省略しました @msg object {0} cannot have children オブジェクト {0} には子オブジェクトを含められません @msg range `{s}` is empty 範囲 `{s}` が空です @@ -4231,7 +4292,7 @@ swatch Bright Violet ビビッドバイオレット @msg {name}: no local header {name}: ローカルヘッダーがありません @msg Flatten Transparency: select objects to flatten 透明部分を分割・統合: 分割・統合するオブジェクトを選択してください @msg Flatten Transparency: {e} 透明部分を分割・統合: {e} -@msg balance must be between 0 and 100 バランスは 0 から 100 までの値にしてください +@msg balance must be between 0 and 100 balance は 0 から 100 までの値にしてください @msg resolutions must be between 1 and 2400 ppi 解像度は 1 から 2400 ppi までの値にしてください @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) `{name}` は不明なプリセットです (high、medium、low、または保存したプリセット: flattener.presets.list を参照してください) @msg `at` must be [x, y] `at` は [x, y] の形式で指定してください @@ -4247,6 +4308,7 @@ swatch Bright Violet ビビッドバイオレット @msg no point {i} (the gradient has {_1}) ポイント {i} はありません (このグラデーションのポイントは {_1} 個です) @msg no segment {segment} on line {line} ライン {line} にセグメント {segment} はありません @msg unknown freeform mode `{m}` (points, lines) `{m}` は不明なフリーフォームのモードです (points、lines) +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` には "none"、"sharp"、"crisp" または "strong" を指定してください @msg `aspect` must be a number (%) `aspect` には数値 (%) を指定してください @msg `gradient` must be an object `gradient` にはオブジェクトを指定してください @msg `groupColors` must be an array `groupColors` には配列を指定してください @@ -4401,7 +4463,7 @@ swatch Bright Violet ビビッドバイオレット @msg no artboard {i} (0-based; the document has {_1}) アートボード {i} はありません (番号は 0 始まり、このドキュメントには {_1} 個あります) @msg no selected slice in the image: select slices, or save all of them 画像内に選択されたスライスがありません: スライスを選択するか、すべてのスライスを保存してください @msg nothing to save: the document has no visible art 保存するものがありません: ドキュメントに表示されているアートがありません -@msg percent must be above 0 and at most 6400, not {p} パーセントは 0 より大きく 6400 以下にしてください ({p} は指定できません) +@msg percent must be above 0 and at most 6400, not {p} percent は 0 より大きく 6400 以下にしてください ({p} は指定できません) @msg unknown preset `{name}` (see webExport.presets.list) `{name}` は不明なプリセットです (webExport.presets.list を参照してください) @msg width and height are at least 1 pixel 幅と高さは 1 ピクセル以上にしてください @msg `sampleSize` must be 1, 3 or 5 `sampleSize` は 1、3、5 のいずれかにしてください @@ -4443,6 +4505,7 @@ swatch Bright Violet ビビッドバイオレット @msg an image VectorCraft can't read was left out VectorCraft で読み込めない画像は除外されました @msg graphs come in as plain art, without their data or settings グラフは通常のアートとして読み込まれ、データと設定は保持されません @msg an image whose data is cut short was left out データが途中で切れている画像は除外されました +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers レイヤーを保持するため、{_1} × {_2} ピクセルの埋め込み画像を {_3} × {_4} ピクセルに縮小しました @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel リンク画像は、元のファイルが見つかるまで、ファイルに保存された低解像度のプレビューで表示されます:リンクパネルで再リンクしてください @msg linked type areas past VectorCraft's limits were left out VectorCraft の上限を超えるリンクされたテキストエリアは省略されました @msg type that changes writing direction inside a text object opens in the direction of its first character テキストオブジェクト内で書字方向が変わる文字は、最初の文字の方向で開きます @@ -4451,7 +4514,10 @@ swatch Bright Violet ビビッドバイオレット @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise 表示に使われるフォントの 1 行に対して低すぎるフレーム内のエリア内文字は、フレームを高くしました ({_1}): そうしないと文字が表示されません @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it グラデーション「{name}」はファイル内で定義されていません: 代わりに単色を使用しています @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) VectorCraft がまだ読み込めないブラシ {_1} 個はブラシパネルに追加されませんでした -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 塗りや線が複数ある、または効果やブラシを適用したオブジェクト {_1} 個は見た目のまま読み込まれました: アピアランスはまだ編集できません +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 塗りや線が複数ある、可変幅の線がある、または効果やブラシを適用したオブジェクト {_1} 個は見た目のまま読み込まれました: アピアランスはまだ編集できません +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding 名前 `{name}` は UTF-8 ではないため {label} として読み込みました: 正しくない場合は別の legacyNameEncoding でファイルを開いてください +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding 名前 `{name}` は UTF-8 ではなく、一部は {label} としても無効です: 別の legacyNameEncoding でファイルを開いてください +@msg more names that aren't UTF-8 were read as {label} ({count}) UTF-8 ではないほかの名前を {label} として読み込みました ({count}) @msg patterns nested too deeply are filled with mid-grey 入れ子が深すぎるパターンは中間のグレーで塗りつぶされています @msg shadings of an unknown type were left out 種類が不明なシェーディングは除外されました @msg shadings whose functions are of an unknown type are filled with their middle colour 関数の種類が不明なシェーディングは中間の色で塗りつぶされています @@ -4564,7 +4630,7 @@ swatch Bright Violet ビビッドバイオレット @msg is not an image VectorCraft reads VectorCraft で読み込める画像ではありません @msg not found 見つかりません @msg SVG parse error: {0} SVG を解析できませんでした: {0} -@msg decimals must be {_1}–{_2} (got {_3}) 小数点以下の桁数は {_1} から {_2} までにしてください (指定値: {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals は {_1} から {_2} までにしてください (指定値: {_3}) @msg Exporting {_1} {_1} を書き出し中 @msg Packaging {_1} {_1} をパッケージ中 @msg Placing {_1} {_1} を配置中 @@ -4640,6 +4706,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 Preview in Browser needs the desktop app (the web downloads files instead) 「ブラウザーでプレビュー」にはデスクトップアプリが必要です (Web 版では代わりにファイルをダウンロードします) @msg no file system here この環境にはファイルシステムがありません @msg nothing to preview プレビューするものがありません @@ -4726,6 +4799,8 @@ swatch Bright Violet ビビッドバイオレット @msg no such action set 指定されたアクションセットが見つかりません @msg the action sets changed; nothing was deleted アクションセットが変更されたため、何も削除しませんでした @msg no action set to save 保存するアクションセットがありません +@msg the action set file can't be read in full アクションセットファイルをすべて読み込めません +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 「{name}」を読み込みました。{n} 個のステップは省きました @msg not an action set file アクションセットファイルではありません @msg the action set file is larger than 8 MiB アクションセットファイルが 8 MiB を超えています @msg the action set file holds more than 1,000 actions アクションセットファイルのアクションが 1,000 個を超えています @@ -4734,7 +4809,7 @@ swatch Bright Violet ビビッドバイオレット @msg choose a folder to export to 書き出し先のフォルダーを選択してください @msg the selection has no URL (or they differ) 選択したオブジェクトに URL がありません (または URL がそれぞれ異なります) @msg select some type first 先にテキストを選択してください -@msg kerning must be a number, "auto", or "optical" カーニングは数値、「auto」または「optical」にしてください +@msg kerning must be a number, "auto", or "optical" kerning は数値、「auto」または「optical」にしてください @msg the selection has no gradient fill 選択したオブジェクトに塗りのグラデーションがありません @msg the selection has no gradient stroke 選択したオブジェクトに線のグラデーションがありません @msg the selection has no pattern fill 選択したオブジェクトに塗りのパターンがありません @@ -4859,6 +4934,7 @@ swatch Bright Violet ビビッドバイオレット @msg object ids are non-negative integers, not {v} オブジェクト ID は 0 以上の整数です ({v} は使えません) @msg {key} must be an array of non-negative integer object ids {key} にはオブジェクト ID (0 以上の整数) の配列を指定してください @msg invalid object id {value}: expected a non-negative integer オブジェクト ID {value} は正しくありません: 0 以上の整数を指定してください +@msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` の要素 {index} は {value} で、オブジェクト ID (0 以上の整数) ではありません @msg no such object id {value} ID が {value} のオブジェクトは見つかりません @msg id {value} is a layer, not an object ID {value} はオブジェクトではなくレイヤーです @msg id {value} is a layer, not an object; move layers with layer.move ID {value} はオブジェクトではなくレイヤーです。レイヤーは layer.move で移動してください @@ -4885,12 +4961,19 @@ swatch Bright Violet ビビッドバイオレット @msg the Pixar file's image data is damaged Pixar ファイルの画像データが壊れています @msg the JPEG 2000 file can't be read JPEG 2000 ファイルを読み込めません @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 ファイルには未対応の色空間または JPX コンテンツがあります +@msg the HEIC or HEIF file can't be read HEIC または HEIF ファイルを読み込めません +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC または HEIF ファイルには読み込めない種類の画像が含まれています +@msg HEIC and HEIF images can't be opened in this build このビルドでは HEIC と HEIF の画像を開けません @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 画像が大きすぎて読み込めません({_1} × {_2} ピクセル) @msg zipped 32-bit PSD image data with prediction can't be read 予測付き ZIP 圧縮の 32 bit PSD 画像データは読み込めません @msg {depth}-bit PSD files in this colour mode can't be read このカラーモードの {depth} bit PSD ファイルは読み込めません @msg the PSD file is cut short PSD ファイルが途中で切れています @msg the PSD file's header is damaged PSD ファイルのヘッダーが破損しています @msg the PSD file's image data can't be read: {e} PSD ファイルの画像データを読み込めません:{e} +@msg 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 ビデオ De-Interlace… インターレース解除… De-Interlace インターレース解除 @@ -4949,6 +5032,9 @@ effect Horizontal 水平 effect Vertical 垂直 Edge エッジ System Title Bar システムのタイトルバー + VectorCraft Title Bar on Every Desktop すべてのデスクトップで VectorCraft のタイトルバー + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. 環境変数 VECTORCRAFT_TITLEBAR が設定されている間は、この変数でタイトルバーが決まります。 + System Title Bar takes precedence over this option while it is on. システムのタイトルバーがオンの間は、そちらがこのオプションより優先されます。 Value Axis 数値軸 On Left Side 左側 On Right Side 右側 @@ -4996,7 +5082,7 @@ effect Vertical 垂直 @msg before must name a panel before にパネルを指定してください @msg docking action contains an unknown panel or an excessive split path ドッキング操作に不明なパネルまたは長すぎる分割パスが含まれています @msg group must be true or false group は true または false にしてください -@msg panel is not in a dock group パネルはドックのグループにありません +@msg panel is not in a dock group panel はドックのグループにありません @msg only a dock group above or below others collapses, not one beside another 折りたためるのは他のグループの上下にあるドックのグループだけで、横に並んだグループは折りたためません @msg icon must be true or false icon は true または false である必要があります @msg icon only docks a panel in the icon column, with no anchor icon はパネルをアイコン列にのみドッキングします(anchor なし) @@ -5162,6 +5248,11 @@ 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 {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: PNG、JPEG、WebP、TIFF、BMP のいずれの画像でもありません +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ 画像は {iw} × {ih} ピクセルで、その幅でのアートボードは {rw} × {rh} ピクセルです: 縦横比が異なります +@msg `threshold` must be a number from 0 to 1 `threshold` には 0 から 1 までの数値を指定してください +@msg background {v}: a color such as "#808080" 背景に {v} は指定できません: 「#808080」のようなカラーを指定してください +@msg comparing with an image file isn't available in the browser 画像ファイルとの比較はブラウザーでは使用できません @msg an image of {w} × {h} pixels is shown at reduced size {w} × {h} ピクセルの画像は縮小して表示されます @msg the image can't be read as a texture この画像はテクスチャとして読み込めません Basic 基本 @@ -5361,11 +5452,19 @@ lighting Light ライト @msg too many library panels were opened in this session このセッションで開いたライブラリパネルが多すぎます @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 画像は {_1} × {_2} ピクセルで、一辺が {LARGE_TRACE_SIDE} を超えています。トレースはできますが、時間がかかります。忠実度の低いプリセットや画像の小さいコピーを使うと速くなります。 @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 画像は {_3} × {_4} から縮小した {_1} × {_2} ピクセルでトレースされました (最大 {_5} メガピクセル)。 +@msg byColor must be true or false byColor は true または false にしてください +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor は自動グループ化のグループを分割できません: 自動グループ化なしでトレースしてください +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor はライブ Pathfinder 効果のあるトレースを再グループ化できません: byColor なしで拡張してください Snapping スナップ Snap to Tangent 接線にスナップ Snap to Perpendicular 垂線にスナップ @msg unknown snap option `{key}` (use {options}) 不明なスナップオプション `{key}` ({options} を使用してください) Gap Length: ギャップの長さ: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. フラットロゴモードは keepColors を使いません。logoColors で指定したカラー、または検出したカラーをトレースします。 +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). 何もトレースされませんでした。トレースで検出したカラーのどれも keepColors ({_1}) に一致しません。 +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. 何もトレースされませんでした。「カラーを無視」が keepColors に指定したカラーの領域をすべて除外します。 +@msg Nothing was traced: the colors that match keepColors produce no paths. 何もトレースされませんでした。keepColors に一致するカラーからパスができません。 +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: トレースで検出したカラーのどれも {c} に一致しないため、このカラーは何もトレースされません。 @msg `colorGroup` needs `library: "document"` `colorGroup` には `library: "document"` が必要です @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` には `palette: "documentLibrary"` が必要です @msg `colorGroup` needs `mode: "color"` `colorGroup` には `mode: "color"` が必要です @@ -5502,11 +5601,14 @@ axis Y Axis Y 軸 @msg Running “{name}”… 「{name}」を実行中… @msg a live effect runs while the document is drawn, so it can't be `heavy` ライブ効果はドキュメントの描画中に実行されるため、`heavy` にはできません @msg an exporter always gets an export's budget: leave `budget` out 書き出しプラグインには常に書き出しの予算が割り当てられます。`budget` は省略してください -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees 「{_1}」は大きな計算予算を求めています (1 回の実行につき最大 {minutes} 分、通常の {times} 倍の命令)。ユーザーが同意したら `allowHeavy: true` を指定してインストールしてください +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants 「{_1}」は大きな計算予算を求めています (1 回の実行につき最大 {minutes} 分、通常の {times} 倍の命令)。デスクトップ アプリで人が承認するか、--allow-plugin-grants を付けて開始したセッションで `allowHeavy: true` を指定してください # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? これらの権限を求める「{name}」をインストールしますか? ID: {id} ID: {id} Version: {version} バージョン: {version} + Install “{name}” in place of the plug-in with the same ID? 同じ ID のプラグインの代わりに「{name}」をインストールしますか? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. このファイルは、以前にこの ID でインストールされていたプラグインではありません。この ID 用に保持されている設定とキャッシュを引き継ぎます。 +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees 「{name}」は ID {_1} のプラグインとは別のモジュールで、そのプラグイン用に保持されている設定とキャッシュを引き継ぐことになります。ユーザーが同意したら `replace: true` を指定してインストールしてください File SHA-256: {hash} ファイルの SHA-256: {hash} Read the objects it is given 渡されたオブジェクトの読み取り Change, add and delete objects in the document ドキュメント内のオブジェクトの変更、追加、削除 @@ -5514,21 +5616,43 @@ 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 ドキュメントを独自形式のファイルとして書き出し Show its progress, see Cancel and write log lines 進行状況の表示、キャンセルの検知、ログ行の書き込み -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 権限 `progress` にはプラグイン API レベル {PROGRESS_API} が必要です。`api` {PROGRESS_API} を指定してください + Keep settings of its own 独自の設定の保持 + Keep a cache of its own, which you can clear 消去できる独自のキャッシュの保持 + Clear Plug-in Cache プラグインのキャッシュを消去 +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 権限 `{permission}` にはプラグイン API レベル {PROGRESS_API} が必要です。`api` {PROGRESS_API} を指定してください @msg the module imports `env.{name}` with another type than the host's: it is {signature} モジュールが `env.{name}` をホストと異なる型でインポートしています。ホストの型は {signature} です -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` モジュールが `env.{name}` をインポートしていますが、プラグインがこれを使用できるのはプラグイン API レベル {PROGRESS_API} (`api` {PROGRESS_API}) で権限 `progress` がある場合だけです -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network モジュールがホストの提供しない `{from}.{name}` をインポートしています。プラグインがインポートできるのは `env.vc_progress`、`env.vc_cancelled`、`env.vc_log` (プラグイン API レベル 2) だけで、WASI、ファイル、ネットワークは使用できません +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` モジュールが `env.{name}` をインポートしていますが、プラグインがこれを使用できるのはプラグイン API レベル {PROGRESS_API} (`api` {PROGRESS_API}) で権限 `{permission}` がある場合だけです +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network モジュールがホストの提供しない `{from}.{name}` をインポートしています。プラグインがインポートできるのは docs/plugins.md に記載された `env.vc_*` 関数 (プラグイン API レベル 2) だけで、WASI、ファイル、ネットワークは使用できません @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` は {_1} です。このホストが対応するプラグイン API レベルは 1 から {API_LEVEL} です +@msg `paintOutput` may only be true `paintOutput` には true のみ指定できます +@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}` が必要です @msg at most {MAX_PERMISSIONS} `permissions` `permissions` は最大 {MAX_PERMISSIONS} 個です @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 権限 `{shown}` は種類 `{_1}` のプラグインには適用されません @msg permission `{shown}` is listed twice 権限 `{shown}` が 2 回指定されています +@msg only a filter declares `inputs` `inputs` を宣言できるのはフィルターだけです +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` に不明なオブジェクトの種類 `{shown}` があります。type、image、symbolInstance のいずれかを使ってください +@msg `inputs` lists `{shown}` twice `inputs` に `{shown}` が 2 回指定されています +@msg `inputs` needs `api` 2 or later `inputs` には `api` 2 以上が必要です +@msg `inputs` needs the permission `doc.read` `inputs` には権限 `doc.read` が必要です +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) メモリ予算 (オブジェクト {_1} には {size} バイトの入力が必要ですが、残りは {left} です) +@msg object budget (more than {MAX_OBJECTS} read-only objects) オブジェクト予算 ({MAX_OBJECTS} 個を超える読み取り専用オブジェクト) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 権限 `{shown}` は今後のプラグイン API 用に予約されています。このホストはこの権限を付与しません @msg unknown permission `{shown}` 不明な権限 `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」は権限 {asked} を求めています。ユーザーが同意したら、それらを列挙した `grant` を指定してインストールしてください +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants 「{name}」は権限 {asked} を求めています。デスクトップ アプリで人が承認するか、--allow-plugin-grants を付けて開始したセッションで `grant` を指定してください +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` と `allowHeavy` は --allow-plugin-grants を付けて開始したセッションでのみ有効です。デスクトップ アプリでプラグインを承認してください +@msg Only the person at the keyboard can approve installing a plug-in プラグインのインストールを承認できるのはキーボードの前にいる人だけです @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed 「{name}」には出力に必要な `{p}` が付与されていません。何も変更されませんでした +@msg a heavy filter runs in the background, so it can't declare `commands` 重いフィルターはバックグラウンドで実行されるため、`commands` を宣言できません +@msg at most {MAX_COMMANDS} `commands` `commands` は最大 {MAX_COMMANDS} 個です +@msg command {name} is declared twice コマンド {name} が 2 回宣言されています +@msg command {name}: `label` must be 1-64 printable characters コマンド {name}: `label` は印刷可能な 1-64 文字にしてください +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter コマンド {name}: a-z 0-9 _ からなり英字で始まる 1-{MAX_COMMAND_ID} 文字の ID を使用してください +@msg command {name}: {e} コマンド {name}: {e} +@msg only a filter or a live effect declares `commands` `commands` を宣言できるのはフィルターかライブ効果だけです +@msg “{name}” › {label}: {e} 「{name}」 › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed モジュールの SHA-256 が `hash` の値と一致しません。何もインストールされませんでした # Actions panel: action sets (#3712). New Set… 新規セット… @@ -5949,6 +6073,8 @@ breakText Glyphs 字形 File + Data Set Name ファイル名 + データセット名 Data Set Name データセット名 @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` は `fileNumber`、`fileDataSetName`、`dataSetName` のいずれかである必要があります + Each data set saves over the same file, which keeps only the last data set. 各データセットが同じファイルに上書き保存されるため、最後のデータセットだけが残ります。 +@msg save the document with Save As before Save and Close 「保存して閉じる」の前に「別名で保存」でドキュメントを保存してください @msg no free data set file name in destination folder 保存先フォルダーに使用可能なデータセットのファイル名がありません # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6069,9 +6195,20 @@ breakText Glyphs 字形 Reset Symbol Instance シンボルインスタンスをリセット @msg type must be static or dynamic, not {v} type は static または dynamic にしてください({v} は無効です) @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` はスタティックシンボルです。先にシンボルの種類をダイナミックに設定してください +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change シンボルインスタンス内で選択したパス、複合パス、テキストは、塗りと線の単色のみ変更できます @msg path must be a list of child indices path は子のインデックスのリストにしてください @msg no object at path {shown} in the symbol's art シンボルのアートのパス {shown} にオブジェクトがありません @msg give fill, stroke, opacity and/or blend fill、stroke、opacity、blend のいずれかを指定してください +@msg {key} cannot be set on a guide ガイドには {key} を設定できません +@msg {key} cannot be set on a blend ブレンドには {key} を設定できません +@msg {key} cannot be set on an envelope エンベロープには {key} を設定できません +@msg {key} cannot be set on a gradient mesh グラデーションメッシュには {key} を設定できません +@msg {key} cannot be set on a Repeat リピートには {key} を設定できません +@msg {key} cannot be set on a placed document 配置したドキュメントには {key} を設定できません +@msg {key} cannot be set on an Intertwine group 織り交ぜグループには {key} を設定できません +@msg stroke cannot be set on a symbol instance シンボルインスタンスには stroke を設定できません +@msg fill on a symbol instance must be a solid color or none シンボルインスタンスの fill は単色または none にしてください +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it 「シェイプに変換」または「オブジェクトのアウトライン」効果のない画像には {key} を設定できません @msg pass newName, registration, exportType, nineSlice and/or type newName、registration、exportType、nineSlice、type のいずれかを指定してください Text Flow テキストの流れ By Rows 行方向 @@ -6101,6 +6238,7 @@ breakText Glyphs 字形 Create Swatch Legend スウォッチの凡例を作成 Create Swatch Legend… スウォッチの凡例を作成… HEX HEX + Help Bar ヘルプバー # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools テキストツール Text Tweaker… テキストの調整… @@ -6166,3 +6304,44 @@ 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 plug-in of kind `{_2}`, not an importer `{_1}` は種類 `{_2}` のプラグインで、読み込みプラグインではありません +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_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` は省略してください + +# Safe Mode + Safe Mode… セーフモード… + Safe Mode セーフモード + Help › Safe Mode… ヘルプ › セーフモード… + Enable on Re-launch 次回起動時に有効にする + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. 前回の VectorCraft の起動は、プラグイン {name} の読み込み中に停止しました。これが原因とは限りません。 + Disable and Continue 無効にして続行 + Start Normally 通常どおり起動 + Start in Safe Mode セーフモードで起動 + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft はセーフモードです: 通常どおり起動するまで、プラグインは読み込まれず実行もされません。 + Disabled plug-ins 無効なプラグイン + No plug-ins are disabled. 無効なプラグインはありません。 +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally セーフモードがオンです: VectorCraft が通常どおり起動するまで、プラグインは読み込まれず実行もされません +@msg give `id` or `file` (see plugin.safeMode) `id` または `file` を指定してください (plugin.safeMode を参照) +@msg that plug-in isn't disabled (see plugin.safeMode) そのプラグインは無効になっていません (plugin.safeMode を参照) +@msg Couldn't save the disabled plug-ins for the next launch 無効なプラグインを次回の起動用に保存できませんでした +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode アピアランスを分割: 選択範囲のプラグイン効果はセーフモードでは分割できません + +# Align to Pixel Grid + Align to Pixel Grid ピクセルグリッドに整合 +@msg only paths and compound paths can be aligned to the pixel grid ピクセルグリッドに整合できるのはパスと複合パスのみです +@msg side is left or right, not `{s}` side は left または right です(`{s}` ではありません) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c710ee3c5..4c331b354 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -74,7 +74,7 @@ CMYK Color CMYK ფერი RGB Color RGB ფერი File Info… ფაილის ინფორმაცია… - Print… დაბეჭდვა… + Print… ბეჭდვა… Edit რედაქტირება Undo დაბრუნება Redo ხელახლა შესრულება @@ -146,6 +146,11 @@ Ungroup დაჯგუფების მოხსნა Lock ჩაკეტვა Selection მონიშნული +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection მართკუთხა მონიშვნა + Touching შეხებული ობიექტები + Fully Enclosed სრულად მოქცეული + Drag Direction გადათრევის მიმართულება All Artwork Above ზემოთ მდებარე მთელი ნახატი Other Layers სხვა შრეები Unlock All ყველაფრის განბლოკვა @@ -261,6 +266,13 @@ Apply Bullets ბურთულების გამოყენება Numbered List დანომრილი სია Apply Numbers დანომრვის გამოყენება + Bullets and Numbering… ბურთულები და დანომრვა… + Numbers ნომრები + Level: დონე: + Bullet Character: ბურთულის სიმბოლო: + Number Style: ნომრის სტილი: + Left Indent: მარცხენა შეწევა: + First Line Indent: პირველი სტრიქონის შეწევა: Convert to Text ტექსტად გარდაქმნა Filled Dot სავსე წერტილი Dash ტირე @@ -386,6 +398,17 @@ Inflate გაბერვა Select objects, then pick an object type to give them a 3D effect. აირჩიეთ ობიექტები, შემდეგ აირჩიეთ ობიექტის ტიპი, რომ მათ 3D ეფექტი მიეცეთ. Extrude & Bevel… ამოწევა და წაკვეთა… + 3D (Classic) 3D (კლასიკური) + Extrude & Bevel (Classic)… ამოწევა და წაკვეთა (კლასიკური)… + Surface ზედაპირი + Wireframe მავთულის ჩარჩო + No Shading დაჩრდილვის გარეშე + Plastic პლასტიკური + Highlight Intensity ელვარების ინტენსივობა + Highlight Size ელვარების ზომა + Blend Steps შერევის საფეხურები + Shading Color დაჩრდილვის ფერი + Draw Hidden Faces დამალული წახნაგების დახატვა Revolve… ბრუნვა… Inflate… გაბერვა… Materials… მასალები… @@ -575,6 +598,7 @@ Toolbars ხელსაწყოთა ზოლები Advanced დამატებითი Double Column ორი სვეტი + Keep Tools Beside the Canvas ხელსაწყოები ტილოს გვერდით Actions მოქმედებები Asset Export რესურსების ექსპორტირება Attributes ატრიბუტები @@ -582,6 +606,11 @@ Color ფერი Color Guide ფერის გზამკვლევი Color Themes ფერის თემები + Color Wheel ფერთა წრე + Hue ring ფერის ტონის რგოლი + Saturation and brightness გაჯერება და სიკაშკაშე + Color field ფერის ველი + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties CSS თვისებები Document Info დოკუმენტის ინფორმაცია Flattener Preview გაბრტყელების წინასწარი ნახვა @@ -768,6 +797,9 @@ Stained Glass… ვიტრაჟი… Texturizer… ტექსტურირება… System Title Bar სისტემის სათაურის ზოლი + VectorCraft Title Bar on Every Desktop VectorCraft-ის სათაურის ზოლი ყველა სამუშაო მაგიდაზე + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. სანამ გარემოს ცვლადი VECTORCRAFT_TITLEBAR დაყენებულია, სათაურის ზოლს ის ირჩევს. + System Title Bar takes precedence over this option while it is on. სანამ სისტემის სათაურის ზოლი ჩართულია, მას ამ პარამეტრზე უპირატესობა აქვს. Exit გასვლა Distort დეფორმაცია Diffuse Glow… გაბნეული ნათება… @@ -1112,7 +1144,7 @@ lighting Light სინათლე Toggle single/double column ერთი/ორი სვეტის გადართვა W: სიგ.: Drag to float the toolbar გადაათრიეთ ხელსაწყოთა ზოლის მცურავად გადასაქცევად - Drag to the window's left edge to dock მისამაგრებლად გადაათრიეთ ფანჯრის მარცხენა კიდესთან + Drag to the window's left or right edge to dock მისამაგრებლად გადაათრიეთ ფანჯრის მარცხენა ან მარჯვენა კიდესთან Crystallize კრისტალიზაცია Add Anchor Point Tool საყრდენი წერტილის დამატების ხელსაწყო Anchor Point Tool საყრდენი წერტილის ხელსაწყო @@ -1243,7 +1275,7 @@ lighting Light სინათლე Dashed ტირეებიანი Delete Selected Item მონიშნული ელემენტის წაშლა Double-click to edit the characters' paint ორჯერ დაწკაპეთ სიმბოლოების შეღებვის რედაქტირებისთვის - Click to edit დააწკაპეთ რედაქტირებისთვის + Click to edit დაწკაპეთ დასარედაქტირებლად Double-click to edit ორჯერ დაწკაპეთ რედაქტირებისთვის Double-click to target the contents ორჯერ დაწკაპეთ შიგთავსის ასარჩევად Drag onto art to apply this appearance ჩაავლეთ ნახატზე ამ იერსახის გამოსაყენებლად @@ -1321,6 +1353,7 @@ lighting Light სინათლე Arabic არაბული Hindi ჰინდი Farsi სპარსული +digits Hindi ჰინდი digits Devanagari დევანაგარი digits Bengali ბენგალური digits Gurmukhi გურმუხი @@ -1350,6 +1383,15 @@ digits Malayalam მალაიალამური Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Tate-chu-yoko პარამეტრები… Tate-chu-yoko Settings Tate-chu-yoko პარამეტრები + Composite Fonts… კომპოზიტური შრიფტები… + Composite Fonts კომპოზიტური შრიფტები + Export Composite Fonts კომპოზიტური შრიფტების ექსპორტი + Import Composite Fonts კომპოზიტური შრიფტების იმპორტი + Delete Font შრიფტის წაშლა + Kanji კანძი + Kana კანა + Numerals ციფრები + Scale at Center ცენტრიდან მასშტაბირება Up/Down: ზემოთ/ქვემოთ: Left/Right: მარცხნივ/მარჯვნივ: Underline ხაზგასმული @@ -1609,6 +1651,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 აბზაცის მიმართულება მარჯვნიდან მარცხნივ @@ -2427,6 +2473,10 @@ digits Malayalam მალაიალამური < 1 sec < 1 წმ [Unnamed] [უსახელო] Amount: რაოდენობა: + Anti-aliasing Method ანტიალიასინგის მეთოდი +antiAlias Sharp მკვეთრი + Crisp მკაფიო + Strong ძლიერი Anti-aliasing: ანტიალიასინგი: Artboard: სამუშაო დაფა: Color Table ფერების ცხრილი @@ -2743,6 +2793,14 @@ theme Appearance იერსახე Asset Export Settings რესურსის ექსპორტის პარამეტრები Assets რესურსები Export Assets რესურსების ექსპორტირება + Export 3D Object 3D ობიექტის ექსპორტირება + 3D Export Format 3D ექსპორტის ფორმატი + Export 3D Assets 3D რესურსების ექსპორტირება +@msg {what} can't be exported in 3D: {e} {what} 3D-ში ვერ ექსპორტირდება: {e} +@msg asset {id} isn't a 3D asset რესურსი {id} არ არის 3D რესურსი +@msg asset {id} is a 3D asset: assets.export3D writes it რესურსი {id} 3D რესურსია: მას assets.export3D წერს +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) 3D რესურსები ჯერ არ არის: ჯერ შეაგროვეთ 3D ეფექტის მქონე ობიექტი (assets.add3D) +@msg select an object with a 3D effect (or give its ids) აირჩიეთ 3D ეფექტის მქონე ობიექტი (ან მიუთითეთ მისი ID) Remove Asset რესურსის წაშლა Rename Asset რესურსის სახელის შეცვლა Fill Rule შევსების წესი @@ -2876,6 +2934,7 @@ theme Appearance იერსახე Document JSON დოკუმენტის JSON Duplicate დუბლირება Find Objects ობიექტების ძებნა + Compare with Image გამოსახულებასთან შედარება Inspect Document დოკუმენტის შემოწმება Inspect Object ობიექტის შემოწმება Begin Undo Group დაბრუნების ჯგუფის დაწყება @@ -2971,7 +3030,7 @@ theme Appearance იერსახე Reverse Order რიგის შებრუნება Show All Layers ყველა შრის ჩვენება Unlock All Layers ყველა შრის განბლოკვა - English: USA English: USA + English: USA ინგლისური: აშშ Move გადატანა Character Style სიმბოლოს სტილი Paragraph Style აბზაცის სტილი @@ -3177,6 +3236,7 @@ theme Appearance იერსახე Feet ფუტები Files ფაილები Fill New Type Objects With Placeholder Text ახალი ტექსტური ობიექტების ჩანაცვლების ტექსტით შევსება + Start Lists From Typed Markers სიების დაწყება აკრეფილი მარკერებიდან Folder საქაღალდე Font Preview Size შრიფტის ნიმუშის ზომა Get Preferences პარამეტრების მიღება @@ -3287,6 +3347,7 @@ theme Appearance იერსახე Yards იარდები Zoom to Selection მონიშნულის მასშტაბირება Zoom with Mouse Wheel მაუსის ბორბლით მასშტაბირება + Scroll Sideways with Cmd/Ctrl + Wheel ჰორიზონტალური გადაადგილება Cmd/Ctrl + ბორბლით Scrub Numeric Fields by Dragging რიცხვითი ველების შეცვლა გადათრევით Linux Display Server Linux-ის ეკრანის სერვერი Print Preview ნახვა ამობეჭდვამდე @@ -3386,7 +3447,7 @@ effect Points წერტილები Discard Empty Type ცარიელი ტექსტის მოცილება Copyright საავტორო უფლება Other სხვა - Print დაბეჭდვა + Print ბეჭდვა Solid მთლიანი Difference სხვაობა Flatten Transparency გამჭვირვალობის გაბრტყელება @@ -3539,6 +3600,7 @@ effect Regular ჩვეულებრივი Find in Folder… საქაღალდეში ძებნა… Replace With Font ჩანაცვლება შრიფტით Replace With Font From ჩანაცვლების შრიფტის წყარო + Find In ძებნის არე System სისტემა — substituted by {style} — ჩანაცვლებულია: {style} — {n} characters from another font — სხვა შრიფტის სიმბოლოები: {n} @@ -3621,7 +3683,7 @@ font-technology Variable ცვლადი Flip: არეკვლა: Spacing: ინტერვალი: Output გამოტანა - Export… ექსპორტი… + Export… ექსპორტირება… Import… იმპორტი… Load Profile… პროფილის ჩატვირთვა… Embedded ჩაშენებული @@ -3764,7 +3826,7 @@ font-technology Variable ცვლადი @plural {n} stroke|{n} strokes კონტური: {n} @msg no library `{id}` ბიბლიოთეკა `{id}` არ არსებობს @msg select some type first ჯერ მონიშნეთ ტექსტი -@msg kerning must be a number, "auto", or "optical" კერნინგი უნდა იყოს რიცხვი, „auto“ ან „optical“ +@msg kerning must be a number, "auto", or "optical" kerning უნდა იყოს რიცხვი, „auto“ ან „optical“ @msg the selection has no gradient fill მონიშნულს შევსების გრადიენტი არ აქვს @msg the selection has no gradient stroke მონიშნულს კონტურის გრადიენტი არ აქვს @msg the selection has no pattern fill მონიშნულს შევსების პატერნი არ აქვს @@ -3856,6 +3918,7 @@ font-technology Variable ცვლადი @msg Split Into Grid: select paths large enough for the grid ბადედ გაყოფა: მონიშნეთ ბადისთვის საკმარისად დიდი ტრაექტორიები @msg Targa files are RGB: colorModel rgb Targa ფაილები RGB-ა: colorModel rgb @msg Text Wrap: select a wrap object ტექსტის შემოვლება: მონიშნეთ შემოსავლელი ობიექტი +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` უნდა იყოს "none", "sharp", "crisp" ან "strong" @msg `aspect` must be a number (%) `aspect` უნდა იყოს რიცხვი (%) @msg `at` must be [x, y] `at` უნდა იყოს [x, y] @msg `color` must be a colour `color` უნდა იყოს ფერი @@ -3995,7 +4058,7 @@ font-technology Variable ცვლადი @msg compound paths can only contain paths შედგენილი ტრაექტორია მხოლოდ ტრაექტორიებს შეიძლება შეიცავდეს @msg compressed (RLE) bitmaps can't be read yet შეკუმშული (RLE) ბიტმაპების წაკითხვა ჯერ არ ხერხდება @msg damaged დაზიანებულია -@msg decimals must be {_1}–{_2} (got {_3}) ციფრები მძიმის შემდეგ უნდა იყოს {_1}–{_2} (მიღებულია {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals უნდა იყოს {_1}–{_2} (მიღებულია {_3}) @msg divide into 1 to {MAX_PIECES} slices დაყავით 1-დან {MAX_PIECES}-მდე ნაჭრად @msg effect {k} is `{_1}`, not `{id}` ეფექტი {k} არის `{_1}` და არა `{id}` @msg empty brush stroke ცარიელი ფუნჯის შტრიხი @@ -4115,6 +4178,8 @@ font-technology Variable ცვლადი @msg no such action set ასეთი მოქმედებების ნაკრები არ არის @msg the action sets changed; nothing was deleted მოქმედებების ნაკრებები შეიცვალა; არაფერი წაშლილა @msg no action set to save შესანახი მოქმედებების ნაკრები არ არის +@msg the action set file can't be read in full მოქმედებების ნაკრების ფაილის სრულად წაკითხვა შეუძლებელია +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out ჩაიტვირთა „{name}“; გამოტოვებული ნაბიჯები: {n} @msg not an action set file ეს არ არის მოქმედებების ნაკრების ფაილი @msg the action set file is larger than 8 MiB მოქმედებების ნაკრების ფაილი 8 MiB-ზე დიდია @msg the action set file holds more than 1,000 actions მოქმედებების ნაკრების ფაილი 1000-ზე მეტ მოქმედებას შეიცავს @@ -4180,9 +4245,9 @@ font-technology Variable ცვლადი @msg replace: the object to replace was changed or deleted replace: ჩასანაცვლებელი ობიექტი შეიცვალა ან წაიშალა @msg pattern missing პატერნი აკლია @msg patterns nested too deeply are filled with mid-grey ზედმეტად ღრმად ჩადგმული პატერნები საშუალო ნაცრისფრით ივსება -@msg percent must be above 0 and at most 6400, not {p} პროცენტი უნდა იყოს 0-ზე მეტი და არაუმეტეს 6400-ისა და არა {p} +@msg percent must be above 0 and at most 6400, not {p} percent უნდა იყოს 0-ზე მეტი და არაუმეტეს 6400-ისა და არა {p} @msg pick a colour, gradient or pattern to save as a swatch ფერის ნიმუშად შესანახად აირჩიეთ ფერი, გრადიენტი ან პატერნი -@msg plane must be left, right or ground სიბრტყე უნდა იყოს left, right ან ground +@msg plane must be left, right or ground plane უნდა იყოს left, right ან ground @msg plug-in ABI: {0} პლაგინის ABI: {0} @msg plug-in exceeded its {0} პლაგინმა გადააჭარბა ლიმიტს: {0} @msg plug-in failed: {0} პლაგინის შეცდომა: {0} @@ -4199,8 +4264,8 @@ font-technology Variable ცვლადი @msg recovery copy `{file}`: {e} აღდგენის ასლი `{file}`: {e} @msg resolutions must be between 1 and 2400 ppi გარჩევადობა უნდა იყოს 1-დან 2400 ppi-მდე @msg result would exceed the canvas (coordinates out of range) შედეგი ტილოს გასცდება (კოორდინატები დიაპაზონს გარეთაა) -@msg scale must be a number from {_1} to {_2}, not {_3} მასშტაბი უნდა იყოს რიცხვი {_1}-დან {_2}-მდე და არა {_3} -@msg scale must be a number from {_1} to {_2}, not {s} მასშტაბი უნდა იყოს რიცხვი {_1}-დან {_2}-მდე და არა {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale უნდა იყოს რიცხვი {_1}-დან {_2}-მდე და არა {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale უნდა იყოს რიცხვი {_1}-დან {_2}-მდე და არა {s} @msg select (or target) objects whose appearance isn't basic მონიშნეთ (ან მიზნად აირჩიეთ) ობიექტები, რომელთა იერსახე საბაზისო არ არის @msg select a Live Paint group მონიშნეთ ცოცხალი შეღებვის ჯგუფი @msg select a blend მონიშნეთ გადასვლა @@ -4221,6 +4286,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 მონიშნეთ ობიექტები და არა შრეები @@ -4443,11 +4515,15 @@ font-technology Variable ცვლადი @msg its objects are nested too deeply მისი ობიექტები ზედმეტად ღრმადაა ჩადგმული @msg its section ending with `%{end}` doesn't end მისი სექცია, რომელიც `%{end}`-ით მთავრდება, არ სრულდება @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) ფუნჯები, რომლებსაც VectorCraft ჯერ ვერ კითხულობს, ფუნჯების პანელში არ დაემატა ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet რამდენიმე შევსების ან კონტურის, ეფექტის ან ფუნჯის მქონე ობიექტები შემოვიდა დახატული სახით ({_1}): მათი იერსახე ჯერ არ რედაქტირდება +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet რამდენიმე შევსების ან კონტურის, ცვლადი სისქის კონტურის, ეფექტის ან ფუნჯის მქონე ობიექტები შემოვიდა დახატული სახით ({_1}): მათი იერსახე ჯერ არ რედაქტირდება +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding სახელი `{name}` არ არის UTF-8 და წაკითხულია როგორც {label}: თუ არასწორად გამოიყურება, გახსენით ფაილი სხვა legacyNameEncoding-ით +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding სახელი `{name}` არ არის UTF-8 და მისი ნაწილები არც {label}-ში არის სწორი: გახსენით ფაილი სხვა legacyNameEncoding-ით +@msg more names that aren't UTF-8 were read as {label} ({count}) სხვა სახელები, რომლებიც არ არის UTF-8, წაკითხულია როგორც {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it გრადიენტი „{name}“ ფაილში განსაზღვრული არ არის: მის ნაცვლად ერთგვაროვანი ფერია @msg an image VectorCraft can't read was left out გამოსახულება, რომელსაც VectorCraft ვერ კითხულობს, გამოტოვებულია @msg graphs come in as plain art, without their data or settings დიაგრამები შემოდის როგორც ჩვეულებრივი გრაფიკა, მათი მონაცემებისა და პარამეტრების გარეშე @msg an image whose data is cut short was left out გამოსახულება, რომლის მონაცემებიც შეკვეცილია, გამოტოვებულია +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers ჩაშენებული გამოსახულება ზომით {_1} × {_2} პიქსელი შემცირდა {_3} × {_4} პიქსელამდე, რომ შრეები შენარჩუნდეს @msg its editing data doesn't say where its art is მისი რედაქტირების მონაცემები არ მიუთითებს, სად არის მისი ნახატი @msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) ეს ფაილი PDF-თავსებადობის გარეშეა შენახული და მისი რედაქტირების მონაცემების წაკითხვა ვერ ხერხდება ({why}) @msg the PDF part couldn't be read ({_1}): the file opened from its editing data PDF ნაწილის წაკითხვა ვერ მოხერხდა ({_1}): ფაილი რედაქტირების მონაცემებიდან გაიხსნა @@ -4475,6 +4551,7 @@ font-technology Variable ცვლადი @msg object ids are non-negative integers, not {v} ობიექტების id არაუარყოფითი მთელი რიცხვებია და არა {v} @msg {key} must be an array of non-negative integer object ids {key} უნდა იყოს ობიექტების id-ების მასივი, სადაც ყოველი id არაუარყოფითი მთელი რიცხვია @msg invalid object id {value}: expected a non-negative integer ობიექტის არასწორი id {value}: მოსალოდნელია არაუარყოფითი მთელი რიცხვი +@msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}`-ის ელემენტი {index} არის {value} და არა ობიექტის id (არაუარყოფითი მთელი რიცხვი) @msg no such object id {value} ობიექტი id-ით {value} არ არსებობს @msg id {value} is a layer, not an object id {value} შრეა და არა ობიექტი @msg id {value} is a layer, not an object; move layers with layer.move id {value} შრეა და არა ობიექტი; შრეების გადასატანად გამოიყენეთ layer.move @@ -4501,12 +4578,19 @@ font-technology Variable ცვლადი @msg the Pixar file's image data is damaged Pixar ფაილის გამოსახულების მონაცემები დაზიანებულია @msg the JPEG 2000 file can't be read JPEG 2000 ფაილის წაკითხვა შეუძლებელია @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 ფაილს აქვს მხარდაუჭერელი ფერთა სივრცე ან JPX შიგთავსი +@msg the HEIC or HEIF file can't be read HEIC ან HEIF ფაილის წაკითხვა შეუძლებელია +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC ან HEIF ფაილი შეიცავს გამოსახულების ტიპს, რომლის წაკითხვაც შეუძლებელია +@msg HEIC and HEIF images can't be opened in this build ამ ვერსიაში HEIC და HEIF გამოსახულებების გახსნა შეუძლებელია @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD გამოსახულება ზედმეტად დიდია წასაკითხად ({_1} × {_2} პიქსელი) @msg zipped 32-bit PSD image data with prediction can't be read zip-ით შეკუმშული 32-ბიტიანი PSD გამოსახულების მონაცემების წაკითხვა პროგნოზირებით ვერ ხერხდება @msg {depth}-bit PSD files in this colour mode can't be read ამ ფერის რეჟიმში {depth}-ბიტიანი PSD ფაილების წაკითხვა ვერ ხერხდება @msg the PSD file is cut short PSD ფაილი შეკვეცილია @msg the PSD file's header is damaged PSD ფაილის სათაური დაზიანებულია @msg the PSD file's image data can't be read: {e} PSD ფაილის გამოსახულების მონაცემების წაკითხვა ვერ ხერხდება: {e} +@msg 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} @msg Invalid panel layout; restored the default dock პანელების განლაგება არასწორია; აღდგენილია ნაგულისხმევი დოკი @msg action is required action აუცილებელია @@ -4514,7 +4598,7 @@ font-technology Variable ცვლადი @msg before must name a panel before უნდა ასახელებდეს პანელს @msg docking action contains an unknown panel or an excessive split path მიმაგრების მოქმედება შეიცავს უცნობ პანელს ან ზედმეტად გრძელ გაყოფის გზას @msg group must be true or false group უნდა იყოს true ან false -@msg panel is not in a dock group პანელი არ არის დოკის ჯგუფში +@msg panel is not in a dock group panel არ არის დოკის ჯგუფში @msg only a dock group above or below others collapses, not one beside another იკეცება მხოლოდ სხვების ზემოთ ან ქვემოთ მდებარე დოკის ჯგუფი, არა სხვის გვერდით მდებარე @msg icon must be true or false icon უნდა იყოს true ან false @msg icon only docks a panel in the icon column, with no anchor icon პანელს მხოლოდ ხატულების სვეტში ამაგრებს, anchor-ის გარეშე @@ -4627,7 +4711,7 @@ font-technology Variable ცვლადი Bicubic ბიკუბური Minimum მინიმალური Run Length Run Length - Roman Roman + Roman რომაული No conversion გარდაქმნის გარეშე Convert to destination სამიზნეში გარდაქმნა Convert to destination (preserve numbers) სამიზნეში გარდაქმნა (რიცხვების შენარჩუნებით) @@ -5049,6 +5133,11 @@ imageTrace Outline კონტური @msg {path}: not a file {path}: ფაილი არ არის Load EPS into Clipboard EPS-ის ბუფერში ჩატვირთვა @msg an image of {w} × {h} pixels is too large to draw {w} × {h} პიქსელის გამოსახულება დასახატად ზედმეტად დიდია +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: არ არის PNG, JPEG, WebP, TIFF ან BMP გამოსახულება +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ გამოსახულება არის {iw} × {ih} პიქსელი, ხოლო სამუშაო დაფა ამ სიგანეზე არის {rw} × {rh} პიქსელი: მათი პროპორციები განსხვავდება +@msg `threshold` must be a number from 0 to 1 `threshold` უნდა იყოს რიცხვი 0-დან 1-მდე +@msg background {v}: a color such as "#808080" ფონი {v}: ფერი, მაგალითად "#808080" +@msg comparing with an image file isn't available in the browser გამოსახულების ფაილთან შედარება ბრაუზერში მიუწვდომელია @msg an image of {w} × {h} pixels is shown at reduced size {w} × {h} პიქსელის გამოსახულება შემცირებული ზომით ჩანს A later row takes this shortcut ამ მალსახმობს ქვემოთა სტრიქონი იყენებს Add to the Current Shortcuts მიმდინარე მალსახმობებზე დამატება @@ -5084,6 +5173,7 @@ imageTrace Outline კონტური Precise Cursors ზუსტი კურსორები Image Trace Preview გამოსახულების ვექტორიზაციის წინასწარი ნახვა Press a key… დააჭირეთ კლავიშს… + Read Action Set File მოქმედებების ნაკრების ფაილის წაკითხვა Read Shortcut Set File მალსახმობების ნაკრების ფაილის წაკითხვა Ready მზადაა Remember Import Matches იმპორტის შესაბამისობების დამახსოვრება @@ -5546,11 +5636,19 @@ imageTrace Outline კონტური @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it „შენახვა“ წერს ფორმატს {_1} და არა {_2}: ამისთვის გამოიყენეთ file.saveAsTemplate @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. გამოსახულება არის {_1} × {_2} პიქსელი, ანუ ერთ მხარეზე {LARGE_TRACE_SIDE}-ზე მეტი: ის ვექტორიზდება, მაგრამ უფრო ნელა. ნაკლები სიზუსტის პრესეტი ან გამოსახულების უფრო პატარა ასლი უფრო სწრაფად ვექტორიზდება. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). გამოსახულება ვექტორიზდა {_1} × {_2} პიქსელზე, შემცირდა {_3} × {_4}-დან (მაქსიმუმ {_5} მეგაპიქსელი). +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. ბრტყელი ლოგოს რეჟიმი keepColors-ს არ იყენებს: ის ვექტორიზებს logoColors-ში მითითებულ ფერებს ან მის მიერ ნაპოვნ ფერებს. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). არაფერი ვექტორიზდა: ვექტორიზაციის მიერ ნაპოვნი არცერთი ფერი არ შეესაბამება keepColors-ს ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. არაფერი ვექტორიზდა: ფერის იგნორირება გამოტოვებს keepColors-ში ჩამოთვლილი ფერების ყველა არეს. +@msg Nothing was traced: the colors that match keepColors produce no paths. არაფერი ვექტორიზდა: keepColors-ის შესაბამისი ფერებიდან არცერთი კონტური არ მიიღება. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: ვექტორიზაციის მიერ ნაპოვნი არცერთი ფერი არ შეესაბამება {c}-ს, ამიტომ მისგან არაფერი ვექტორიზდება. @msg `colorGroup` needs `library: "document"` `colorGroup`-ს სჭირდება `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup`-ს სჭირდება `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup`-ს სჭირდება `mode: "color"` @msg no color group `{name}` in the document (see swatch.list) დოკუმენტში ფერების ჯგუფი `{name}` არ არის (იხ. swatch.list) @msg Flat Logo traced the image at {_1} × {_2} pixels, averaged down from {_3} × {_4}. ბრტყელი ლოგოს რეჟიმში გამოსახულება ვექტორიზდა {_1} × {_2} პიქსელზე, საშუალოს გამოთვლით შემცირდა {_3} × {_4}-დან. +@msg byColor must be true or false byColor უნდა იყოს true ან false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor ვერ დაყოფს ავტომატური დაჯგუფების ჯგუფებს: ვექტორიზება გააკეთეთ ავტომატური დაჯგუფების გარეშე +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor ვერ გადააჯგუფებს ტრასირებას აქტიური Pathfinder ეფექტით: გააფართოვეთ byColor-ის გარეშე @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` ბიბლიოთეკის პანელი არ არის (იხ. window.swatchLibrary) @msg `{key}` must be a #rrggbb color `{key}` უნდა იყოს #rrggbb ფერი @msg a key object sits on that point ამ წერტილზე საკვანძო ობიექტი მდებარეობს @@ -5744,11 +5842,14 @@ axis Y Axis Y ღერძი @msg Running “{name}”… მიმდინარეობს „{name}“-ის გაშვება… @msg a live effect runs while the document is drawn, so it can't be `heavy` ცოცხალი ეფექტი დოკუმენტის დახატვისას ეშვება, ამიტომ ის ვერ იქნება `heavy` @msg an exporter always gets an export's budget: leave `budget` out ექსპორტის პლაგინი ყოველთვის ექსპორტის ბიუჯეტს იღებს: გამოტოვეთ `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ დიდ გამოთვლით ბიუჯეტს ითხოვს (თითო გაშვება {minutes} წუთამდე და ჩვეულებრივზე {times}-ჯერ მეტი ინსტრუქცია): მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `allowHeavy: true` პარამეტრით +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}“ დიდ გამოთვლით ბიუჯეტს ითხოვს (თითო გაშვება {minutes} წუთამდე და ჩვეულებრივზე {times}-ჯერ მეტი ინსტრუქცია): ის დესკტოპის აპში ადამიანმა უნდა დაამტკიცოს, ან გადაეცით `allowHeavy: true` --allow-plugin-grants-ით გაშვებულ სესიაში # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? დავაყენოთ „{name}“, რომელიც ამ ნებართვებს ითხოვს? ID: {id} ID: {id} Version: {version} ვერსია: {version} + Install “{name}” in place of the plug-in with the same ID? დავაყენოთ „{name}“ იმავე ID-ის მქონე პლაგინის ნაცვლად? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. ეს ფაილი არ არის ამ ID-ით ადრე დაყენებული პლაგინი. ის მიიღებს ამ ID-სთვის შენახულ პარამეტრებს და ქეშს. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}“ სხვა მოდულია, ვიდრე პლაგინი ID-ით {_1}, და მიიღებდა მისთვის შენახულ პარამეტრებს და ქეშს: მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `replace: true` პარამეტრით File SHA-256: {hash} ფაილის SHA-256: {hash} Read the objects it is given მიღებული ობიექტების წაკითხვა Change, add and delete objects in the document დოკუმენტში ობიექტების შეცვლა, დამატება და წაშლა @@ -5756,21 +5857,43 @@ 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 დოკუმენტის ჩაწერა ფაილად საკუთარ ფორმატში Show its progress, see Cancel and write log lines პროგრესის ჩვენება, გაუქმების შემჩნევა და ჟურნალის სტრიქონების ჩაწერა -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} ნებართვას `progress` სჭირდება პლაგინის API-ის დონე {PROGRESS_API}: მიუთითეთ `api` {PROGRESS_API} + Keep settings of its own საკუთარი პარამეტრების შენახვა + Keep a cache of its own, which you can clear საკუთარი ქეშის შენახვა, რომლის გასუფთავებაც შეგიძლიათ + Clear Plug-in Cache პლაგინის ქეშის გასუფთავება +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} ნებართვას `{permission}` სჭირდება პლაგინის API-ის დონე {PROGRESS_API}: მიუთითეთ `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} მოდულს შემოაქვს `env.{name}` ჰოსტისგან განსხვავებული ტიპით: ჰოსტის ტიპია {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` მოდულს შემოაქვს `env.{name}`, რომლის გამოყენებაც პლაგინს მხოლოდ პლაგინის API-ის დონეზე {PROGRESS_API} (`api` {PROGRESS_API}) და ნებართვით `progress` შეუძლია -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network მოდულს შემოაქვს `{from}.{name}`, რომელსაც ჰოსტი არ გვთავაზობს: პლაგინს შეუძლია შემოიტანოს მხოლოდ `env.vc_progress`, `env.vc_cancelled` და `env.vc_log` (პლაგინის API-ის დონე 2) და არა WASI, ფაილები ან ქსელი +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` მოდულს შემოაქვს `env.{name}`, რომლის გამოყენებაც პლაგინს მხოლოდ პლაგინის API-ის დონეზე {PROGRESS_API} (`api` {PROGRESS_API}) და ნებართვით `{permission}` შეუძლია +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network მოდულს შემოაქვს `{from}.{name}`, რომელსაც ჰოსტი არ გვთავაზობს: პლაგინს შეუძლია შემოიტანოს მხოლოდ docs/plugins.md-ში ჩამოთვლილი `env.vc_*` ფუნქციები (პლაგინის API-ის დონე 2) და არა WASI, ფაილები ან ქსელი @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` არის {_1}; ეს ჰოსტი პლაგინის API-ის დონეებს 1-დან {API_LEVEL}-მდე უჭერს მხარს +@msg `paintOutput` may only be true `paintOutput` შეიძლება იყოს მხოლოდ true +@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}` @msg at most {MAX_PERMISSIONS} `permissions` მაქსიმუმ {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` ნებართვა `{shown}` არ ეხება `{_1}` სახის პლაგინს @msg permission `{shown}` is listed twice ნებართვა `{shown}` ორჯერაა ჩამოთვლილი +@msg only a filter declares `inputs` `inputs`-ის გამოცხადება მხოლოდ ფილტრს შეუძლია +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` უცნობ ობიექტის სახეობას ასახელებს `{shown}`: გამოიყენეთ type, image ან symbolInstance +@msg `inputs` lists `{shown}` twice `inputs`-ში `{shown}` ორჯერაა ჩამოთვლილი +@msg `inputs` needs `api` 2 or later `inputs`-ს სჭირდება `api` 2 ან უფრო ახალი +@msg `inputs` needs the permission `doc.read` `inputs`-ს სჭირდება ნებართვა `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) მეხსიერების ბიუჯეტი (ობიექტს {_1} სჭირდება შეყვანის {size} ბაიტი, დარჩენილია {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) ობიექტების ბიუჯეტი ({MAX_OBJECTS}-ზე მეტი მხოლოდ წასაკითხი ობიექტი) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it ნებართვა `{shown}` დაჯავშნილია პლაგინების მომავალი API-სთვის; ეს ჰოსტი მას არ გასცემს @msg unknown permission `{shown}` უცნობი ნებართვა `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ ითხოვს ნებართვებს {asked}: მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `grant` პარამეტრით, რომელშიც ისინი ჩამოთვლილია +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants „{name}“ ითხოვს ნებართვებს {asked}: ისინი დესკტოპის აპში ადამიანმა უნდა დაამტკიცოს, ან გადაეცით `grant` --allow-plugin-grants-ით გაშვებულ სესიაში +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` და `allowHeavy` მოქმედებს მხოლოდ --allow-plugin-grants-ით გაშვებულ სესიაში: დაამტკიცეთ დამატება დესკტოპის აპში +@msg Only the person at the keyboard can approve installing a plug-in დამატების დაყენებას მხოლოდ კლავიატურასთან მყოფი ადამიანი ამტკიცებს @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“-ს არ მიეცა `{p}`, რომელიც მის შედეგს სჭირდება: არაფერი შეცვლილა +@msg a heavy filter runs in the background, so it can't declare `commands` მძიმე ფილტრი ფონურ რეჟიმში მუშაობს, ამიტომ მას არ შეუძლია `commands`-ის გამოცხადება +@msg at most {MAX_COMMANDS} `commands` მაქსიმუმ {MAX_COMMANDS} `commands` +@msg command {name} is declared twice ბრძანება {name} ორჯერაა გამოცხადებული +@msg command {name}: `label` must be 1-64 printable characters ბრძანება {name}: `label` უნდა შედგებოდეს 1-64 დასაბეჭდი სიმბოლოსგან +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter ბრძანება {name}: გამოიყენეთ 1-{MAX_COMMAND_ID} სიმბოლოიანი id a-z 0-9 _ სიმბოლოებიდან, რომელიც ასოთი იწყება +@msg command {name}: {e} ბრძანება {name}: {e} +@msg only a filter or a live effect declares `commands` `commands`-ს მხოლოდ ფილტრი ან ცოცხალი ეფექტი აცხადებს +@msg “{name}” › {label}: {e} „{name}“ › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed მოდულს არ აქვს `hash`-ში მითითებული SHA-256: არაფერი დაყენებულა # Actions panel: action sets (#3712). New Set… ახალი ნაკრები… @@ -6074,7 +6197,7 @@ anchor Points: წერტილები: Close Path: ტრაექტორიის შეკვრა: Keep Handles სახელურების შენარჩუნება Straight სწორი - Stretch Curve მრუდის გაჭიმვა + Stretch Curve მრუდის გაწელვა @msg direct-select the anchor points to remove პირდაპირ მონიშნეთ მოსაცილებელი საყრდენი წერტილები @msg select segments, or paths, to add points to მონიშნეთ სეგმენტები ან ტრაექტორიები წერტილების დასამატებლად @msg select open paths to close მონიშნეთ შესაკრავი ღია ტრაექტორიები @@ -6219,7 +6342,7 @@ anchor Points: წერტილები: Join Point Text წერტილის ტექსტის შეერთება Remove Breaks წყვეტების წაშლა Split Text at Cursor ტექსტის დაყოფა კურსორთან - Set Alignment, Keep Position სწორების დაყენება მდებარეობის შენარჩუნებით + Set Alignment, Keep Position სწორების დაყენება პოზიციის შენარჩუნებით Reset Text Transform ტექსტის გარდაქმნის ჩამოყრა Replace Missing Fonts… დაკარგული შრიფტების ჩანაცვლება… Replace Missing Fonts დაკარგული შრიფტების ჩანაცვლება @@ -6230,7 +6353,7 @@ anchor Points: წერტილები: Break Text into Words ტექსტის დაყოფა სიტყვებად Break Text into Glyphs ტექსტის დაყოფა გლიფებად Reset Text Transform and Scale ტექსტის გარდაქმნისა და მასშტაბის ჩამოყრა - Set Alignment, Keep Position: სწორების დაყენება მდებარეობის შენარჩუნებით: + Set Alignment, Keep Position: სწორების დაყენება პოზიციის შენარჩუნებით: Break Text into: ტექსტის დაყოფა: Split at Cursor დაყოფა კურსორთან Reset Transform გარდაქმნის ჩამოყრა @@ -6239,7 +6362,7 @@ anchor Points: წერტილები: breakText Lines სტრიქონები breakText Words სიტყვები breakText Glyphs გლიფები -@msg Click in type with a Type tool to split it დასაყოფად დააწკაპუნეთ ტექსტში ტექსტის ხელსაწყოთი +@msg Click in type with a Type tool to split it დასაყოფად დაწკაპეთ ტექსტში ტექსტის ხელსაწყოთი @msg Too many type objects to change at once ერთდროულად შესაცვლელად ძალიან ბევრი ტექსტური ობიექტია @msg Too many pieces to make at once ერთდროულად შესაქმნელად ძალიან ბევრი ნაწილია @msg Select horizontal point or area type whose text fits აირჩიეთ ჰორიზონტალური წერტილის ან არის ტექსტი, რომლის ტექსტიც ეტევა @@ -6262,6 +6385,8 @@ breakText Glyphs გლიფები File + Data Set Name ფაილი + მონაცემთა ნაკრების სახელი Data Set Name მონაცემთა ნაკრების სახელი @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` უნდა იყოს `fileNumber`, `fileDataSetName` ან `dataSetName` + Each data set saves over the same file, which keeps only the last data set. თითოეული მონაცემთა ნაკრები ერთსა და იმავე ფაილზე ინახება, რომელშიც მხოლოდ ბოლო მონაცემთა ნაკრები რჩება. +@msg save the document with Save As before Save and Close „შენახვა და დახურვამდე“ შეინახეთ დოკუმენტი „შენახვა როგორც“ ბრძანებით @msg no free data set file name in destination folder დანიშნულების საქაღალდეში მონაცემთა ნაკრებისთვის თავისუფალი ფაილის სახელი არ არის # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6337,9 +6462,9 @@ breakText Glyphs გლიფები Fit to Corners and Ends კუთხეებსა და ბოლოებზე მორგება Remove Gaps ღრეჩოების მოცილება @msg that makes too many pieces (at most 100000): make the dashes longer ეს ძალიან ბევრ ნაწილს იძლევა (მაქსიმუმ 100000): გაზარდეთ ტირეების სიგრძე -@msg select paths, or segments, to divide აირჩიეთ დასაყოფი კონტურები ან სეგმენტები +@msg select paths, or segments, to divide აირჩიეთ დასაყოფი ტრაექტორიები ან სეგმენტები # Path Precision Tools add-on: Expand Dashes into Paths. - Expand Dashes into Paths ტირეების კონტურებად გაშლა + Expand Dashes into Paths ტირეების ტრაექტორიებად გაშლა @msg select paths with dashed strokes to expand აირჩიეთ გასაშლელი ტირეებიანი ხაზის კონტურები # Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy. Document Tools დოკუმენტის ხელსაწყოები @@ -6381,9 +6506,20 @@ breakText Glyphs გლიფები Reset Symbol Instance სიმბოლოს ეგზემპლარის ჩამოყრა @msg type must be static or dynamic, not {v} type უნდა იყოს static ან dynamic და არა {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` სტატიკური სიმბოლოა: ჯერ სიმბოლოს ტიპად დააყენეთ „დინამიკური“ +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change სიმბოლოს ეგზემპლარში მონიშნულ ტრაექტორიას, შედგენილ ტრაექტორიას ან ტექსტს მხოლოდ შევსებისა და კონტურის მთლიანი ფერი შეეცვლება @msg path must be a list of child indices path უნდა იყოს შვილობილი ინდექსების სია @msg no object at path {shown} in the symbol's art სიმბოლოს გრაფიკაში გზაზე {shown} ობიექტი არ არის @msg give fill, stroke, opacity and/or blend მიუთითეთ fill, stroke, opacity და/ან blend +@msg {key} cannot be set on a guide {key} ვერ დაყენდება დამხმარე ხაზზე +@msg {key} cannot be set on a blend {key} ვერ დაყენდება გადასვლაზე +@msg {key} cannot be set on an envelope {key} ვერ დაყენდება გარსზე +@msg {key} cannot be set on a gradient mesh {key} ვერ დაყენდება გრადიენტულ ბადეზე +@msg {key} cannot be set on a Repeat {key} ვერ დაყენდება გამეორებაზე +@msg {key} cannot be set on a placed document {key} ვერ დაყენდება განთავსებულ დოკუმენტზე +@msg {key} cannot be set on an Intertwine group {key} ვერ დაყენდება გადახლართვის ჯგუფზე +@msg stroke cannot be set on a symbol instance stroke ვერ დაყენდება სიმბოლოს ეგზემპლარზე +@msg fill on a symbol instance must be a solid color or none სიმბოლოს ეგზემპლარის fill უნდა იყოს ერთფეროვანი ფერი ან none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} ვერ დაყენდება გამოსახულებაზე „ფიგურად გარდაქმნის“ ან „ობიექტის კონტურებად გარდაქმნის“ ეფექტის გარეშე @msg pass newName, registration, exportType, nineSlice and/or type მიუთითეთ newName, registration, exportType, nineSlice და/ან type Text Flow ტექსტის დინება By Rows სტრიქონების მიხედვით @@ -6413,6 +6549,7 @@ breakText Glyphs გლიფები Create Swatch Legend ფერის ნიმუშების ლეგენდის შექმნა Create Swatch Legend… ფერის ნიმუშების ლეგენდის შექმნა… HEX HEX + Help Bar დახმარების ზოლი Check for Updates… განახლებების შემოწმება… VectorCraft Updates VectorCraft-ის განახლებები Preparing the image… გამოსახულების მომზადება… @@ -6427,6 +6564,7 @@ font-script Japanese იაპონური Japanese Double Postcard იაპონური ორმაგი ღია ბარათი Japanese Envelope #3 იაპონური კონვერტი #3 Japanese Envelope #4 იაპონური კონვერტი #4 +@msg image '{key}' has no pixels and was left out გამოსახულებას '{key}' პიქსელები არ აქვს და გამოტოვდა @msg not an RTF file: it doesn't start with {\\rtf RTF ფაილი არ არის: არ იწყება {\\rtf-ით # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools ტექსტის ხელსაწყოები @@ -6438,6 +6576,54 @@ font-script Japanese იაპონური Bow რკალი Spike პიკი Skip Spaces ჰარების გამოტოვება + +# The strings the report listed as missing for ka. + 1 Layer შრე: 1 + Art and Bright Colors მხატვრული და კაშკაშა ფერები + Automate ავტომატიზაცია + Color Table (4 colors) ფერების ცხრილი (ფერი: 4) + Current Colors (0) მიმდინარე ფერები (0) + Differences from VectorCraft Default: განსხვავებები VectorCraft-ის ნაგულისხმევთან: + Differences from [Default]: განსხვავებები [ნაგულისხმევი]-თან: + Dot Gain 15% Dot Gain 15% + Expand 1 Selected 1 მონიშნული ობიექტის გაშლა + Graphic Styles (4) გრაფიკული სტილები (4) + Gray Gamma 2.2 Gray Gamma 2.2 + Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono, Noto Sans SC (OFL). Icons: Lucide (ISC) + VectorCraft. ArtCraft-ის ნაწილი. MIT OR Apache-2.0. შრიფტები: Source Sans 3, Inter, JetBrains Mono, Noto Sans SC (OFL). ხატულები: Lucide (ISC) + VectorCraft. + Phone 375×812 ტელეფონი 375×812 + Photo Low Fidelity დაბალი სიზუსტის ფოტო + Print & Proof ბეჭდვა და კორექტურა + Printing & Proofing ბეჭდვა და კორექტურა + RECENT (1) ბოლოდროინდელი (1) + Reset Automation Automation-ის ჩამოყრა + Reset Essentials Classic Essentials Classic-ის ჩამოყრა + Reset Layout Layout-ის ჩამოყრა + Reset Painting Painting-ის ჩამოყრა + Reset Printing and Proofing Printing and Proofing-ის ჩამოყრა + Reset Tracing Tracing-ის ჩამოყრა + Reset Typography Typography-ის ჩამოყრა + Reset Web Web-ის ჩამოყრა + Restricted Colors შეზღუდული ფერები + Right-click an item to move it into this group. ამ ჯგუფში გადასატანად დაწკაპეთ ელემენტზე მარჯვენა ღილაკით. + Scale X მასშტაბი X + Scale X: მასშტაბი X: + Scale Y მასშტაბი Y + Scale Y: მასშტაბი Y: + Selected Styles Only (0) მხოლოდ მონიშნული სტილები (0) + Selected Swatches Only (0) მხოლოდ მონიშნული ფერის ნიმუშები (0) + Spot Colors (0) სპოტ-ფერები (0) + Tablet 1024×768 პლანშეტი 1024×768 + Video 1920×1080 ვიდეო 1920×1080 + Video 3840×2160 ვიდეო 3840×2160 + Web 1280×720 ვები 1280×720 + [High Resolution] [მაღალი გარჩევადობა] + [Low Resolution] [დაბალი გარჩევადობა] + [Medium Resolution] [საშუალო გარჩევადობა] + [PDF/X-1a:2001] [PDF/X-1a:2001] + [PDF/X-3:2002] [PDF/X-3:2002] + [PDF/X-4:2010] [PDF/X-4:2010] + [VectorCraft Default] [VectorCraft-ის ნაგულისხმევი] +@msg give corners of the path: anchors between two straight sides მიუთითეთ ტრაექტორიის კუთხეები: საყრდენი წერტილები ორ სწორ გვერდს შორის Release Type on a Path… ტექსტის ტრაექტორიიდან მოხსნა… Release Type on a Path ტექსტის ტრაექტორიიდან მოხსნა Text and Path ტექსტი და ტრაექტორია @@ -6493,3 +6679,44 @@ 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 plug-in of kind `{_2}`, not an importer `{_1}` არის `{_2}` სახის პლაგინი და არა იმპორტის პლაგინი +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_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` + +# Safe Mode + Safe Mode… უსაფრთხო რეჟიმი… + Safe Mode უსაფრთხო რეჟიმი + Help › Safe Mode… დახმარება › უსაფრთხო რეჟიმი… + Enable on Re-launch ჩართვა შემდეგ გაშვებისას + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. VectorCraft-ის ბოლო გაშვება შეჩერდა, როცა პლაგინი {name} იტვირთებოდა. შესაძლოა, მიზეზი ის არ იყოს. + Disable and Continue გამორთვა და გაგრძელება + Start Normally ჩვეულებრივ გაშვება + Start in Safe Mode უსაფრთხო რეჟიმში გაშვება + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft უსაფრთხო რეჟიმშია: პლაგინები არ იტვირთება და არ ეშვება, სანამ პროგრამა ჩვეულებრივ არ გაეშვება. + Disabled plug-ins გამორთული პლაგინები + No plug-ins are disabled. გამორთული პლაგინები არ არის. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally უსაფრთხო რეჟიმი ჩართულია: პლაგინები არ იტვირთება და არ ეშვება, სანამ VectorCraft ჩვეულებრივ არ გაეშვება +@msg give `id` or `file` (see plugin.safeMode) მიუთითეთ `id` ან `file` (იხ. plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) ეს პლაგინი გამორთული არ არის (იხ. plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch გამორთული პლაგინების შენახვა შემდეგი გაშვებისთვის ვერ მოხერხდა +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode იერსახის გაშლა: მონიშნულში პლაგინის ეფექტის გაშლა უსაფრთხო რეჟიმში შეუძლებელია + +# Align to Pixel Grid + Align to Pixel Grid პიქსელურ ბადეზე გასწორება +@msg only paths and compound paths can be aligned to the pixel grid პიქსელურ ბადეზე მხოლოდ ტრაექტორიების და შედგენილი ტრაექტორიების გასწორებაა შესაძლებელი +@msg side is left or right, not `{s}` side არის left ან right და არა `{s}` diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c0545a3da..615b1159e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -69,6 +69,10 @@ digits Malayalam 말라얄람어 Stylistic 스타일 Mojikumi Set 모지쿠미 세트 Kinsoku Set 긴소쿠 세트 + Kinsoku Shori Type 긴소쿠 처리 유형 + Push In First 밀어 넣기 우선 + Push Out First 밀어내기 우선 + Push Out Only 밀어내기만 Hard 강하게 Soft 약하게 Latin 라틴어 @@ -147,6 +151,7 @@ digits Malayalam 말라얄람어 Find and Replace… 찾기 및 바꾸기… Find Next 다음 찾기 Find Objects 개체 찾기 + Compare with Image 이미지와 비교 Spelling 맞춤법 Auto Spell Check 자동 맞춤법 검사 Check Spelling… 맞춤법 검사… @@ -205,6 +210,11 @@ digits Malayalam 말라얄람어 Ungroup 그룹 해제 Lock 잠그기 Selection 선택 영역 +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection 사각형 선택 + Touching 닿는 개체 + Fully Enclosed 완전히 포함된 개체 + Drag Direction 드래그 방향 All Artwork Above 위의 모든 아트워크 Other Layers 다른 레이어 Unlock All 모두 잠금 해제 @@ -342,6 +352,13 @@ digits Malayalam 말라얄람어 Apply Bullets 글머리 기호 적용 Numbered List 번호 매기기 목록 Apply Numbers 번호 매기기 적용 + Bullets and Numbering… 글머리 기호 및 번호 매기기… + Numbers 번호 + Level: 수준: + Bullet Character: 글머리 기호 문자: + Number Style: 번호 스타일: + Left Indent: 왼쪽 들여쓰기: + First Line Indent: 첫 줄 들여쓰기: Convert to Text 텍스트로 변환 Filled Dot 채운 점 Dash 대시 @@ -486,6 +503,17 @@ digits Malayalam 말라얄람어 Inflate 부풀리기 Select objects, then pick an object type to give them a 3D effect. 개체를 선택한 다음 개체 유형을 골라 3D 효과를 적용하십시오. Extrude & Bevel… 돌출 및 경사… + 3D (Classic) 3D(클래식) + Extrude & Bevel (Classic)… 돌출 및 경사(클래식)… + Surface 표면 + Wireframe 와이어프레임 + No Shading 셰이딩 없음 + Plastic 플라스틱 + Highlight Intensity 하이라이트 강도 + Highlight Size 하이라이트 크기 + Blend Steps 혼합 단계 + Shading Color 셰이딩 색상 + Draw Hidden Faces 숨겨진 면 그리기 Revolve… 회전체… Inflate… 부풀리기… Materials… 재질… @@ -671,6 +699,7 @@ digits Malayalam 말라얄람어 Toolbars 도구 모음 Advanced 고급 Double Column 2열 + Keep Tools Beside the Canvas 캔버스 옆에 도구 배치 Actions 액션 Asset Export 에셋 내보내기 Attributes 속성 @@ -678,6 +707,11 @@ digits Malayalam 말라얄람어 Color 색상 Color Guide 색상 가이드 Color Themes 색상 테마 + Color Wheel 색상환 + Hue ring 색조 링 + Saturation and brightness 채도 및 밝기 + Color field 색상 필드 + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties CSS 속성 Document Info 문서 정보 Flattener Preview 병합 미리 보기 @@ -969,6 +1003,10 @@ digits Malayalam 말라얄람어 Anti-alias 안티 앨리어싱 Anti-alias Rasters 래스터 안티 앨리어싱 Anti-aliased Artwork 안티 앨리어싱 아트워크 + Anti-aliasing Method 안티 앨리어싱 방식 +antiAlias Sharp 날카롭게 + Crisp 또렷하게 + Strong 강하게 Anti-aliasing: 안티 앨리어싱: Appearance Item 모양새 항목 Appearance Panel 모양새 패널 @@ -1572,6 +1610,14 @@ digits Malayalam 말라얄람어 PDF Print 인쇄용 PDF Export As 다른 형식으로 내보내기 Export Assets 에셋 내보내기 + Export 3D Object 3D 개체 내보내기 + 3D Export Format 3D 내보내기 형식 + Export 3D Assets 3D 에셋 내보내기 +@msg {what} can't be exported in 3D: {e} {what}은(는) 3D로 내보낼 수 없습니다: {e} +@msg asset {id} isn't a 3D asset 에셋 {id}은(는) 3D 에셋이 아닙니다 +@msg asset {id} is a 3D asset: assets.export3D writes it 에셋 {id}은(는) 3D 에셋입니다: assets.export3D로 내보냅니다 +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) 아직 3D 에셋이 없습니다: 먼저 3D 효과가 있는 개체를 수집하세요 (assets.add3D) +@msg select an object with a 3D effect (or give its ids) 3D 효과가 있는 개체를 선택하세요 (또는 ID를 지정하세요) Export CSS CSS 내보내기 Export CSS… CSS 내보내기… Export DXF DXF 내보내기 @@ -1624,6 +1670,7 @@ digits Malayalam 말라얄람어 Files of this standard tag their colours with ICC profiles. 이 표준의 파일은 ICC 프로파일로 색상을 태그합니다. Fill 칠 Fill New Type Objects With Placeholder Text 새 문자 개체를 대체 텍스트로 채우기 + Start Lists From Typed Markers 입력한 기호로 목록 시작 Fill Rule 칠 규칙 Fill and Stroke 칠과 선 Fill new strokes 새 선 칠하기 @@ -1946,6 +1993,7 @@ digits Malayalam 말라얄람어 Line {line} {line}행 Line {line}: {name}: {status} {line}행: {name}: {status} Imported. Press OK to keep the imported set. 가져왔습니다. 가져온 세트를 유지하려면 확인을 누르세요. + Read Action Set File 액션 세트 파일 읽기 Read Shortcut Set File 단축키 세트 파일 읽기 Import Matches 가져오기 대응 Remember Import Matches 가져오기 대응 기억 @@ -2765,6 +2813,7 @@ mark-style Japanese 일본식 Reset Transformation 변형 재설정 Replace With Font 바꿀 글꼴 Replace With Font From 바꿀 글꼴 목록 + Find In 찾을 위치 System 시스템 Replace it with another file, or ignore it: its images keep showing the preview saved with the document. 다른 파일로 바꾸거나 무시하세요. 무시하면 해당 이미지는 문서에 저장된 미리 보기로 계속 표시됩니다. Replaces the saved preset of that name. 같은 이름으로 저장된 사전 설정을 대체합니다. @@ -3224,6 +3273,8 @@ mark-style Japanese 일본식 Tile Range: 타일 범위: Tile Type: 타일 유형: Tiles are numbered across, then down; leave the range empty for every tile. 타일은 가로로 먼저, 이어서 세로로 번호가 매겨집니다. 모든 타일을 지정하려면 범위를 비워 두십시오. + Copies per Sheet (as many as fit, in a grid) 용지당 여러 매 (들어가는 만큼 격자로 배치) + Gutter: 간격: Toggle Fill/Stroke Focus 칠/선 포커스 전환 Toggle Selection 선택 전환 Toggle single/double column 한 줄/두 줄 전환 @@ -3396,6 +3447,7 @@ mark-style Japanese 일본식 Zoom Tool 확대/축소 도구 Zoom to Selection 선택 항목에 맞춰 확대/축소 Zoom with Mouse Wheel 마우스 휠로 확대/축소 + Scroll Sideways with Cmd/Ctrl + Wheel Cmd/Ctrl + 휠로 가로 스크롤 Scrub Numeric Fields by Dragging 드래그로 숫자 필드 조절 [Default] is protected: New… starts an editable copy. [Default]는 보호되어 있습니다. 새로 만들기…를 누르면 편집 가능한 복사본이 만들어집니다. [Unnamed] [이름 없음] @@ -3792,6 +3844,15 @@ file-filter Templates 템플릿 Tate-chu-yoko 다테추요코 Tate-chu-yoko Settings… 다테추요코 설정… Tate-chu-yoko Settings 다테추요코 설정 + Composite Fonts… 합성 글꼴… + Composite Fonts 합성 글꼴 + Export Composite Fonts 합성 글꼴 내보내기 + Import Composite Fonts 합성 글꼴 가져오기 + Delete Font 글꼴 삭제 + Kanji 한자 + Kana 가나 + Numerals 숫자 + Scale at Center 중심에서 크기 조정 Up/Down: 위/아래: Left/Right: 왼쪽/오른쪽: Temperature 색온도 @@ -3929,7 +3990,7 @@ axis Both 양쪽 Dock to Bottom 아래쪽에 고정 Dock Control Bar 컨트롤 막대 고정 Drag to float the toolbar 드래그하여 도구 모음 분리 - Drag to the window's left edge to dock 창 왼쪽 가장자리로 드래그하여 고정 + Drag to the window's left or right edge to dock 창 왼쪽 또는 오른쪽 가장자리로 드래그하여 고정 Float Tool Group 도구 그룹 분리 Services 서비스 Hide VectorCraft VectorCraft 숨기기 @@ -4033,14 +4094,14 @@ theme Appearance 외관 @msg The last start of VectorCraft ended on {name} before its window appeared, so it started on {summary}. To try that graphics processor again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set 지난번 VectorCraft 시작이 창이 나타나기 전에 {name}에서 끝나서 {summary}에서 시작했습니다. 이 그래픽 프로세서를 다시 시도하려면 환경 설정 › 성능 › 그래픽 프로세서를 변경하거나 WGPU_BACKEND를 설정하고 시작하세요 @msg The last start of VectorCraft ended while it listed the graphics processors, so it started on {summary} with OpenGL alone. To try the other graphics backends again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set 지난번 VectorCraft 시작이 그래픽 프로세서 목록을 가져오는 중에 끝나서 OpenGL만 사용해 {summary}에서 시작했습니다. 다른 그래픽 백엔드를 다시 시도하려면 환경 설정 › 성능 › 그래픽 프로세서를 변경하거나 WGPU_BACKEND를 설정하고 시작하세요 @msg not a DXF drawing DXF 도면이 아닙니다 -@msg scale must be a number from {_1} to {_2}, not {s} 배율은 {_1}에서 {_2} 사이의 숫자여야 합니다: {s} +@msg scale must be a number from {_1} to {_2}, not {s} scale은 {_1}에서 {_2} 사이의 숫자여야 합니다: {s} @msg the artboard to fit must be a positive size up to {_1} pt 맞출 아트보드는 최대 {_1} pt까지의 양수 크기여야 합니다 @msg the drawing has no layout `{_1}` (it has {_2}) 도면에 레이아웃 `{_1}`이(가) 없습니다(있는 레이아웃: {_2}) @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that DWG 도면은 읽을 수 없습니다. CAD 앱에서 ASCII DXF로 저장한 후 여세요 @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard 이 배율에서는 도면이 {w} × {h} pt로 캔버스 크기를 넘습니다. 더 작은 배율을 고르거나 아트보드에 맞추세요 @msg the scale is not a usable number 배율이 사용할 수 없는 숫자입니다 @msg the drawing is too large to open 도면이 너무 커서 열 수 없습니다 -@msg scale must be a number from {_1} to {_2}, not {_3} 배율은 {_1}에서 {_2} 사이의 숫자여야 합니다: {_3} +@msg scale must be a number from {_1} to {_2}, not {_3} scale은 {_1}에서 {_2} 사이의 숫자여야 합니다: {_3} @msg an image has no pixels and was left out 픽셀이 없는 이미지를 제외했습니다 @msg object {0} cannot have children 개체 {0}에는 하위 항목을 둘 수 없습니다 @msg range `{s}` is empty 범위 `{s}`이(가) 비어 있습니다 @@ -4158,7 +4219,7 @@ theme Appearance 외관 @msg {name}: no local header {name}: 로컬 헤더가 없습니다 @msg Flatten Transparency: select objects to flatten 투명도 병합: 병합할 개체를 선택하세요 @msg Flatten Transparency: {e} 투명도 병합: {e} -@msg balance must be between 0 and 100 균형은 0에서 100 사이여야 합니다 +@msg balance must be between 0 and 100 balance는 0에서 100 사이여야 합니다 @msg resolutions must be between 1 and 2400 ppi 해상도는 1에서 2400 ppi 사이여야 합니다 @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) 알 수 없는 프리셋 `{name}`(high, medium, low 또는 저장된 프리셋: flattener.presets.list 참조) @msg `at` must be [x, y] `at`은 [x, y]여야 합니다 @@ -4174,6 +4235,7 @@ theme Appearance 외관 @msg no point {i} (the gradient has {_1}) 점 {i}이(가) 없습니다(그레이디언트의 점 수: {_1}) @msg no segment {segment} on line {line} 선 {line}에 세그먼트 {segment}이(가) 없습니다 @msg unknown freeform mode `{m}` (points, lines) 알 수 없는 자유형 모드 `{m}`(points, lines) +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias`는 "none", "sharp", "crisp" 또는 "strong"이어야 합니다 @msg `aspect` must be a number (%) `aspect`는 숫자(%)여야 합니다 @msg `gradient` must be an object `gradient`는 개체여야 합니다 @msg `groupColors` must be an array `groupColors`는 배열이어야 합니다 @@ -4233,7 +4295,7 @@ theme Appearance 외관 @msg nothing to align 정렬할 항목이 없습니다 @msg select a clip group 클립 그룹을 선택하세요 @msg select two or more objects to distribute 분포할 개체를 두 개 이상 선택하세요 -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` 축은 "vertical", "horizontal" 또는 도 단위 각도여야 합니다: `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis는 "vertical", "horizontal" 또는 도 단위 각도여야 합니다: `{v}` @msg give horizontal or vertical horizontal 또는 vertical을 지정하세요 @msg selection has no bounds 선택 영역에 경계가 없습니다 @msg the top object must be a path, compound path or text object to use as a clipping mask 클리핑 마스크로 쓰려면 맨 위 개체가 패스, 컴파운드 패스 또는 텍스트 개체여야 합니다 @@ -4328,7 +4390,7 @@ theme Appearance 외관 @msg no artboard {i} (0-based; the document has {_1}) 아트보드 {i}이(가) 없습니다(0부터 시작, 문서의 아트보드 수: {_1}) @msg no selected slice in the image: select slices, or save all of them 이미지에 선택한 슬라이스가 없습니다. 슬라이스를 선택하거나 모두 저장하세요 @msg nothing to save: the document has no visible art 저장할 항목이 없습니다. 문서에 보이는 아트웍이 없습니다 -@msg percent must be above 0 and at most 6400, not {p} 퍼센트는 0 초과 6400 이하여야 합니다: {p} +@msg percent must be above 0 and at most 6400, not {p} percent는 0 초과 6400 이하여야 합니다: {p} @msg unknown preset `{name}` (see webExport.presets.list) 알 수 없는 프리셋 `{name}`(webExport.presets.list 참조) @msg width and height are at least 1 pixel 너비와 높이는 1픽셀 이상이어야 합니다 @msg `sampleSize` must be 1, 3 or 5 `sampleSize`는 1, 3 또는 5여야 합니다 @@ -4370,6 +4432,7 @@ theme Appearance 외관 @msg an image VectorCraft can't read was left out VectorCraft가 읽을 수 없는 이미지는 제외했습니다 @msg graphs come in as plain art, without their data or settings 그래프는 데이터와 설정 없이 일반 아트로 가져왔습니다 @msg an image whose data is cut short was left out 데이터가 잘린 이미지는 제외했습니다 +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers 레이어를 유지하기 위해 {_1} × {_2} 픽셀의 포함된 이미지를 {_3} × {_4} 픽셀로 줄였습니다 @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel 연결된 그림은 해당 파일을 찾을 때까지 파일에 저장된 저해상도 미리 보기로 표시됩니다: 링크 패널에서 다시 연결하세요 @msg linked type areas past VectorCraft's limits were left out VectorCraft 한도를 넘는 연결된 문자 영역은 제외되었습니다 @msg type that changes writing direction inside a text object opens in the direction of its first character 문자 개체 안에서 쓰기 방향이 바뀌는 문자는 첫 글자의 방향으로 열립니다 @@ -4378,7 +4441,10 @@ theme Appearance 외관 @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise 표시되는 글꼴의 한 줄보다 낮은 프레임의 영역 문자는 프레임을 더 높였습니다({_1}). 그렇지 않으면 문자가 표시되지 않습니다 @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 그레이디언트 “{name}”이(가) 파일에 정의되어 있지 않아 단색으로 대체했습니다 @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) VectorCraft에서 아직 읽을 수 없는 브러시는 브러시 패널에서 제외되었습니다({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 칠이나 선이 여러 개인 개체, 효과, 브러시가 있는 개체는 그려진 모양({_1})으로 가져왔습니다: 모양새는 아직 편집할 수 없습니다 +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 칠이나 선이 여러 개인 개체, 폭이 변하는 선, 효과, 브러시가 있는 개체는 그려진 모양({_1})으로 가져왔습니다: 모양새는 아직 편집할 수 없습니다 +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding 이름 `{name}`은(는) UTF-8이 아니어서 {label}(으)로 읽었습니다: 잘못 보이면 다른 legacyNameEncoding으로 파일을 여세요 +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding 이름 `{name}`은(는) UTF-8이 아니고 일부는 {label}에서도 올바르지 않습니다: 다른 legacyNameEncoding으로 파일을 여세요 +@msg more names that aren't UTF-8 were read as {label} ({count}) UTF-8이 아닌 다른 이름도 {label}(으)로 읽었습니다({count}) @msg patterns nested too deeply are filled with mid-grey 너무 깊게 중첩된 패턴은 중간 회색으로 채웁니다 @msg shadings of an unknown type were left out 알 수 없는 종류의 셰이딩은 제외했습니다 @msg shadings whose functions are of an unknown type are filled with their middle colour 함수 종류를 알 수 없는 셰이딩은 중간 색상으로 채웁니다 @@ -4491,7 +4557,7 @@ theme Appearance 외관 @msg is not an image VectorCraft reads VectorCraft가 읽는 이미지가 아닙니다 @msg not found 찾을 수 없습니다 @msg SVG parse error: {0} SVG 구문 분석 오류: {0} -@msg decimals must be {_1}–{_2} (got {_3}) 소수 자릿수는 {_1}~{_2}이어야 합니다({_3} 입력됨) +@msg decimals must be {_1}–{_2} (got {_3}) decimals는 {_1}~{_2}이어야 합니다({_3} 입력됨) @msg Exporting {_1} {_1} 내보내는 중 @msg Packaging {_1} {_1} 패키지 만드는 중 @msg Placing {_1} {_1} 가져오는 중 @@ -4549,7 +4615,7 @@ theme Appearance 외관 @msg no preset editor 프리셋 편집기가 없습니다 @msg no perspective grid preset named `{name}` `{name}`이라는 원근 격자 프리셋이 없습니다 @msg no active perspective plane 활성 원근 평면이 없습니다 -@msg plane must be left, right or ground 평면은 왼쪽, 오른쪽 또는 바닥이어야 합니다 +@msg plane must be left, right or ground plane은 left, right 또는 ground여야 합니다 @msg missing `id` (see plugin.list) `id`가 없습니다(plugin.list 참조) @msg no plug-in {id} is installed 플러그인 {id}이(가) 설치되어 있지 않습니다 @msg {_1} is a live effect (Effect › Plug-ins) {_1}은(는) 라이브 효과입니다(효과 › 플러그인) @@ -4567,6 +4633,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 Preview in Browser needs the desktop app (the web downloads files instead) 브라우저에서 미리 보기는 데스크톱 앱이 필요합니다(웹에서는 파일을 다운로드합니다) @msg no file system here 여기에는 파일 시스템이 없습니다 @msg nothing to preview 미리 볼 항목이 없습니다 @@ -4653,6 +4726,8 @@ theme Appearance 외관 @msg no such action set 해당 액션 세트가 없습니다 @msg the action sets changed; nothing was deleted 액션 세트가 바뀌어 아무것도 삭제하지 않았습니다 @msg no action set to save 저장할 액션 세트가 없습니다 +@msg the action set file can't be read in full 액션 세트 파일을 전부 읽을 수 없습니다 +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}”을(를) 불러왔습니다. 단계 {n}개를 제외했습니다 @msg not an action set file 액션 세트 파일이 아닙니다 @msg the action set file is larger than 8 MiB 액션 세트 파일이 8 MiB보다 큽니다 @msg the action set file holds more than 1,000 actions 액션 세트 파일의 액션이 1,000개를 넘습니다 @@ -4661,7 +4736,7 @@ theme Appearance 외관 @msg choose a folder to export to 내보낼 폴더를 선택하세요 @msg the selection has no URL (or they differ) 선택 항목에 URL이 없거나 서로 다릅니다 @msg select some type first 먼저 문자를 선택하세요 -@msg kerning must be a number, "auto", or "optical" 커닝은 숫자, “auto” 또는 “optical”이어야 합니다 +@msg kerning must be a number, "auto", or "optical" kerning은 숫자, “auto” 또는 “optical”이어야 합니다 @msg the selection has no gradient fill 선택 항목에 칠 그레이디언트가 없습니다 @msg the selection has no gradient stroke 선택 항목에 선 그레이디언트가 없습니다 @msg the selection has no pattern fill 선택 항목에 칠 패턴이 없습니다 @@ -4786,6 +4861,7 @@ theme Appearance 외관 @msg object ids are non-negative integers, not {v} 개체 id는 음이 아닌 정수여야 하며 {v}은(는) 안 됩니다 @msg {key} must be an array of non-negative integer object ids {key}은(는) 음이 아닌 정수인 개체 id의 배열이어야 합니다 @msg invalid object id {value}: expected a non-negative integer 개체 id {value}이(가) 올바르지 않습니다: 음이 아닌 정수여야 합니다 +@msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}`의 항목 {index}은(는) {value}이며, 음이 아닌 정수인 개체 id가 아닙니다 @msg no such object id {value} id가 {value}인 개체가 없습니다 @msg id {value} is a layer, not an object id {value}은(는) 개체가 아닌 레이어입니다 @msg id {value} is a layer, not an object; move layers with layer.move id {value}은(는) 개체가 아닌 레이어입니다. 레이어는 layer.move로 이동하세요 @@ -4812,12 +4888,19 @@ theme Appearance 외관 @msg the Pixar file's image data is damaged Pixar 파일의 이미지 데이터가 손상되었습니다 @msg the JPEG 2000 file can't be read JPEG 2000 파일을 읽을 수 없습니다 @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 파일에 지원되지 않는 색 공간 또는 JPX 콘텐츠가 있습니다 +@msg the HEIC or HEIF file can't be read HEIC 또는 HEIF 파일을 읽을 수 없습니다 +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC 또는 HEIF 파일에 읽을 수 없는 종류의 이미지가 있습니다 +@msg HEIC and HEIF images can't be opened in this build 이 빌드에서는 HEIC 및 HEIF 이미지를 열 수 없습니다 @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 이미지가 너무 커서 읽을 수 없습니다({_1} × {_2}픽셀) @msg zipped 32-bit PSD image data with prediction can't be read 예측이 적용된 ZIP 압축 32비트 PSD 이미지 데이터는 읽을 수 없습니다 @msg {depth}-bit PSD files in this colour mode can't be read 이 색상 모드의 {depth}비트 PSD 파일은 읽을 수 없습니다 @msg the PSD file is cut short PSD 파일이 잘려 있습니다 @msg the PSD file's header is damaged PSD 파일의 헤더가 손상되었습니다 @msg the PSD file's image data can't be read: {e} PSD 파일의 이미지 데이터를 읽을 수 없습니다: {e} +@msg 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 비디오 De-Interlace… 디인터레이스… De-Interlace 디인터레이스 @@ -4876,6 +4959,9 @@ effect Horizontal 가로 effect Vertical 세로 Edge 가장자리 System Title Bar 시스템 제목 표시줄 + VectorCraft Title Bar on Every Desktop 모든 데스크톱에서 VectorCraft 제목 표시줄 + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. 환경 변수 VECTORCRAFT_TITLEBAR가 설정되어 있는 동안에는 이 변수가 제목 표시줄을 정합니다. + System Title Bar takes precedence over this option while it is on. 시스템 제목 표시줄이 켜져 있는 동안에는 그 설정이 이 옵션보다 우선합니다. Value Axis 값 축 On Left Side 왼쪽 On Right Side 오른쪽 @@ -4921,7 +5007,7 @@ effect Vertical 세로 @msg before must name a panel before는 패널을 지정해야 합니다 @msg docking action contains an unknown panel or an excessive split path 도킹 동작에 알 수 없는 패널이나 지나치게 많은 분할 경로가 있습니다 @msg group must be true or false group은 true 또는 false여야 합니다 -@msg panel is not in a dock group 패널이 도크 그룹에 없습니다 +@msg panel is not in a dock group panel이 도크 그룹에 없습니다 @msg only a dock group above or below others collapses, not one beside another 다른 그룹의 위나 아래에 있는 도크 그룹만 접을 수 있으며, 옆에 있는 그룹은 접을 수 없습니다 @msg icon must be true or false icon은 true 또는 false여야 합니다 @msg icon only docks a panel in the icon column, with no anchor icon은 anchor 없이 패널을 아이콘 열에만 도킹합니다 @@ -5081,6 +5167,11 @@ 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 {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: PNG, JPEG, WebP, TIFF 또는 BMP 이미지가 아닙니다 +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ 이미지는 {iw} × {ih}픽셀이고 그 너비에서 아트보드는 {rw} × {rh}픽셀입니다: 비율이 다릅니다 +@msg `threshold` must be a number from 0 to 1 `threshold`은(는) 0에서 1 사이의 숫자여야 합니다 +@msg background {v}: a color such as "#808080" 배경 {v}: "#808080" 같은 색상이어야 합니다 +@msg comparing with an image file isn't available in the browser 이미지 파일과 비교하는 기능은 브라우저에서 사용할 수 없습니다 @msg an image of {w} × {h} pixels is shown at reduced size {w} × {h}픽셀 이미지는 축소된 크기로 표시됩니다 @msg the image can't be read as a texture 이미지를 텍스처로 읽을 수 없습니다 Basic 기본 @@ -5279,11 +5370,19 @@ lighting Light 광원 @msg too many library panels were opened in this session 이 세션에서 라이브러리 패널을 너무 많이 열었습니다 @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 이미지가 {_1} × {_2}픽셀로 한 변이 {LARGE_TRACE_SIDE}을 넘습니다. 추적은 되지만 더 느립니다. 충실도가 낮은 사전 설정이나 이미지의 작은 사본을 사용하면 더 빠릅니다. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 이미지를 {_3} × {_4}에서 축소한 {_1} × {_2}픽셀로 추적했습니다(최대 {_5}메가픽셀). +@msg byColor must be true or false byColor는 true 또는 false여야 합니다 +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor는 자동 그룹화 그룹을 나눌 수 없습니다. 자동 그룹화 없이 추적하세요 +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor는 라이브 Pathfinder 효과가 있는 추적을 다시 그룹화할 수 없습니다. byColor 없이 확장하세요 Snapping 스냅 Snap to Tangent 접선에 스냅 Snap to Perpendicular 수직선에 스냅 @msg unknown snap option `{key}` (use {options}) 알 수 없는 스냅 옵션 `{key}` ({options} 중에서 사용하세요) Gap Length: 간격 길이: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. 단색 로고 모드는 keepColors를 사용하지 않습니다. logoColors에 지정한 색상 또는 찾은 색상을 추적합니다. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). 아무것도 추적하지 않았습니다. 추적에서 찾은 색상 중 keepColors({_1})와 일치하는 색상이 없습니다. +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. 아무것도 추적하지 않았습니다. 색상 무시가 keepColors에 나열된 색상의 모든 영역을 제외합니다. +@msg Nothing was traced: the colors that match keepColors produce no paths. 아무것도 추적하지 않았습니다. keepColors와 일치하는 색상에서 패스가 만들어지지 않습니다. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: 추적에서 찾은 색상 중 {c}와 일치하는 색상이 없어 이 색상은 추적하지 않습니다. @msg `colorGroup` needs `library: "document"` `colorGroup`에는 `library: "document"`가 필요합니다 @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup`에는 `palette: "documentLibrary"`가 필요합니다 @msg `colorGroup` needs `mode: "color"` `colorGroup`에는 `mode: "color"`가 필요합니다 @@ -5398,11 +5497,14 @@ axis Y Axis Y축 @msg Running “{name}”… “{name}” 실행 중… @msg a live effect runs while the document is drawn, so it can't be `heavy` 라이브 효과는 문서를 그리는 동안 실행되므로 `heavy`일 수 없습니다 @msg an exporter always gets an export's budget: leave `budget` out 내보내기 플러그인은 항상 내보내기 예산을 받습니다. `budget`은 생략하세요 -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}”은(는) 큰 계산 예산을 요청합니다(실행당 최대 {minutes}분, 평소의 {times}배 명령어). 사용자가 동의하면 `allowHeavy: true`로 설치하세요 +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}”은(는) 큰 계산 예산을 요청합니다(실행당 최대 {minutes}분, 평소의 {times}배 명령어). 데스크톱 앱에서 사람이 승인하거나 --allow-plugin-grants로 시작한 세션에서 `allowHeavy: true`를 전달하세요 # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? 이 권한을 요청하는 “{name}”을(를) 설치하시겠습니까? ID: {id} ID: {id} Version: {version} 버전: {version} + Install “{name}” in place of the plug-in with the same ID? 같은 ID의 플러그인 대신 “{name}”을(를) 설치하시겠습니까? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. 이 파일은 이전에 이 ID로 설치된 플러그인이 아닙니다. 이 ID에 보관된 설정과 캐시를 받습니다. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}”은(는) ID {_1}의 플러그인과 다른 모듈이며 그 플러그인에 보관된 설정과 캐시를 받게 됩니다. 사용자가 동의하면 `replace: true`로 설치하세요 File SHA-256: {hash} 파일 SHA-256: {hash} Read the objects it is given 전달받은 개체 읽기 Change, add and delete objects in the document 문서의 개체 변경, 추가 및 삭제 @@ -5410,21 +5512,43 @@ 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 문서를 자체 형식의 파일로 쓰기 Show its progress, see Cancel and write log lines 진행률 표시, 취소 감지 및 로그 줄 쓰기 -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `progress` 권한에는 플러그인 API 수준 {PROGRESS_API}이(가) 필요합니다. `api` {PROGRESS_API}을(를) 지정하십시오 + Keep settings of its own 자체 설정 보관 + Keep a cache of its own, which you can clear 지울 수 있는 자체 캐시 보관 + Clear Plug-in Cache 플러그인 캐시 지우기 +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `{permission}` 권한에는 플러그인 API 수준 {PROGRESS_API}이(가) 필요합니다. `api` {PROGRESS_API}을(를) 지정하십시오 @msg the module imports `env.{name}` with another type than the host's: it is {signature} 모듈이 `env.{name}`을(를) 호스트와 다른 형식으로 가져옵니다. 호스트의 형식은 {signature}입니다 -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` 모듈이 `env.{name}`을(를) 가져오지만, 플러그인은 이를 플러그인 API 수준 {PROGRESS_API}(`api` {PROGRESS_API})에서 `progress` 권한이 있을 때만 사용할 수 있습니다 -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network 모듈이 호스트가 제공하지 않는 `{from}.{name}`을(를) 가져옵니다. 플러그인은 `env.vc_progress`, `env.vc_cancelled`, `env.vc_log`(플러그인 API 수준 2)만 가져올 수 있으며 WASI, 파일, 네트워크는 사용할 수 없습니다 +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` 모듈이 `env.{name}`을(를) 가져오지만, 플러그인은 이를 플러그인 API 수준 {PROGRESS_API}(`api` {PROGRESS_API})에서 `{permission}` 권한이 있을 때만 사용할 수 있습니다 +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network 모듈이 호스트가 제공하지 않는 `{from}.{name}`을(를) 가져옵니다. 플러그인은 docs/plugins.md에 나열된 `env.vc_*` 함수(플러그인 API 수준 2)만 가져올 수 있으며 WASI, 파일, 네트워크는 사용할 수 없습니다 @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api`가 {_1}입니다. 이 호스트는 플러그인 API 수준 1부터 {API_LEVEL}까지 지원합니다 +@msg `paintOutput` may only be true `paintOutput`에는 true만 지정할 수 있습니다 +@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}` 권한이 필요합니다 @msg at most {MAX_PERMISSIONS} `permissions` `permissions`는 최대 {MAX_PERMISSIONS}개입니다 @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` `{shown}` 권한은 `{_1}` 종류의 플러그인에 적용되지 않습니다 @msg permission `{shown}` is listed twice `{shown}` 권한이 두 번 나열되어 있습니다 +@msg only a filter declares `inputs` 필터만 `inputs`를 선언할 수 있습니다 +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs`에 알 수 없는 개체 종류 `{shown}`이(가) 있습니다. type, image 또는 symbolInstance를 사용하세요 +@msg `inputs` lists `{shown}` twice `inputs`에 `{shown}`이(가) 두 번 나열되어 있습니다 +@msg `inputs` needs `api` 2 or later `inputs`에는 `api` 2 이상이 필요합니다 +@msg `inputs` needs the permission `doc.read` `inputs`에는 `doc.read` 권한이 필요합니다 +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) 메모리 예산(개체 {_1}에 입력 {size}바이트가 필요하지만 {left}바이트 남음) +@msg object budget (more than {MAX_OBJECTS} read-only objects) 개체 예산(읽기 전용 개체 {MAX_OBJECTS}개 초과) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` 권한은 이후 플러그인 API용으로 예약되어 있습니다. 이 호스트는 이 권한을 부여하지 않습니다 @msg unknown permission `{shown}` 알 수 없는 권한 `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”은(는) {asked} 권한을 요청합니다. 사용자가 동의하면 이를 나열한 `grant`로 설치하세요 +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}”은(는) {asked} 권한을 요청합니다. 데스크톱 앱에서 사람이 승인하거나 --allow-plugin-grants로 시작한 세션에서 `grant`를 전달하세요 +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant`와 `allowHeavy`는 --allow-plugin-grants로 시작한 세션에서만 적용됩니다. 데스크톱 앱에서 플러그인을 승인하세요 +@msg Only the person at the keyboard can approve installing a plug-in 플러그인 설치는 키보드 앞에 있는 사람만 승인할 수 있습니다 @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}”에 출력에 필요한 `{p}` 권한이 부여되지 않았습니다. 아무것도 변경되지 않았습니다 +@msg a heavy filter runs in the background, so it can't declare `commands` 무거운 필터는 백그라운드에서 실행되므로 `commands`를 선언할 수 없습니다 +@msg at most {MAX_COMMANDS} `commands` `commands`는 최대 {MAX_COMMANDS}개입니다 +@msg command {name} is declared twice 명령 {name}이(가) 두 번 선언되었습니다 +@msg command {name}: `label` must be 1-64 printable characters 명령 {name}: `label`은 인쇄 가능한 문자 1-64자여야 합니다 +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter 명령 {name}: a-z 0-9 _로 이루어지고 문자로 시작하는 1-{MAX_COMMAND_ID}자 ID를 사용하세요 +@msg command {name}: {e} 명령 {name}: {e} +@msg only a filter or a live effect declares `commands` 필터나 라이브 효과만 `commands`를 선언합니다 +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 모듈의 SHA-256이 `hash`에 지정된 값과 다릅니다. 아무것도 설치되지 않았습니다 # Actions panel: action sets (#3712). New Set… 새 세트… @@ -5845,6 +5969,8 @@ breakText Glyphs 글리프 File + Data Set Name 파일 + 데이터 세트 이름 Data Set Name 데이터 세트 이름 @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming`은 `fileNumber`, `fileDataSetName` 또는 `dataSetName`이어야 합니다 + Each data set saves over the same file, which keeps only the last data set. 각 데이터 세트가 같은 파일에 덮어써지므로 마지막 데이터 세트만 남습니다. +@msg save the document with Save As before Save and Close '저장 후 닫기' 전에 '다른 이름으로 저장'으로 문서를 저장하십시오 @msg no free data set file name in destination folder 대상 폴더에 사용할 수 있는 데이터 세트 파일 이름이 없습니다 # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -5965,9 +6091,20 @@ breakText Glyphs 글리프 Reset Symbol Instance 심볼 인스턴스 재설정 @msg type must be static or dynamic, not {v} type은 static 또는 dynamic이어야 합니다({v}는 안 됨) @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}`은(는) 정적 심볼입니다. 먼저 심볼 유형을 동적으로 설정하세요 +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change 심볼 인스턴스에서 선택한 패스, 컴파운드 패스 또는 문자는 칠과 선의 단색만 변경할 수 있습니다 @msg path must be a list of child indices path는 자식 인덱스 목록이어야 합니다 @msg no object at path {shown} in the symbol's art 심볼 아트의 경로 {shown}에 개체가 없습니다 @msg give fill, stroke, opacity and/or blend fill, stroke, opacity 및/또는 blend를 지정하세요 +@msg {key} cannot be set on a guide 안내선에는 {key}을(를) 설정할 수 없습니다 +@msg {key} cannot be set on a blend 블렌드에는 {key}을(를) 설정할 수 없습니다 +@msg {key} cannot be set on an envelope 엔벨로프에는 {key}을(를) 설정할 수 없습니다 +@msg {key} cannot be set on a gradient mesh 그레이디언트 메시에는 {key}을(를) 설정할 수 없습니다 +@msg {key} cannot be set on a Repeat 반복에는 {key}을(를) 설정할 수 없습니다 +@msg {key} cannot be set on a placed document 가져온 문서에는 {key}을(를) 설정할 수 없습니다 +@msg {key} cannot be set on an Intertwine group 교차 그룹에는 {key}을(를) 설정할 수 없습니다 +@msg stroke cannot be set on a symbol instance 심볼 인스턴스에는 stroke를 설정할 수 없습니다 +@msg fill on a symbol instance must be a solid color or none 심볼 인스턴스의 fill은 단색 또는 none이어야 합니다 +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it 모양으로 변환 또는 개체 윤곽선화 효과가 없는 이미지에는 {key}을(를) 설정할 수 없습니다 @msg pass newName, registration, exportType, nineSlice and/or type newName, registration, exportType, nineSlice 및/또는 type을 지정하세요 Text Flow 텍스트 흐름 By Rows 행 방향 @@ -5997,6 +6134,7 @@ breakText Glyphs 글리프 Create Swatch Legend 견본 범례 만들기 Create Swatch Legend… 견본 범례 만들기… HEX HEX + Help Bar 도움말 표시줄 # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools 텍스트 도구 Text Tweaker… 텍스트 조정… @@ -6062,3 +6200,44 @@ 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 plug-in of kind `{_2}`, not an importer `{_1}`은(는) `{_2}` 종류의 플러그인이며 가져오기 플러그인이 아닙니다 +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_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`을 생략하세요 + +# Safe Mode + Safe Mode… 안전 모드… + Safe Mode 안전 모드 + Help › Safe Mode… 도움말 › 안전 모드… + Enable on Re-launch 다음 실행 시 활성화 + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. 지난번 VectorCraft 실행이 플러그인 {name}을(를) 불러오는 중에 중지되었습니다. 이 플러그인이 원인이 아닐 수도 있습니다. + Disable and Continue 비활성화하고 계속 + Start Normally 정상적으로 시작 + Start in Safe Mode 안전 모드로 시작 + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft가 안전 모드입니다. 정상적으로 시작할 때까지 플러그인을 불러오거나 실행하지 않습니다. + Disabled plug-ins 비활성화된 플러그인 + No plug-ins are disabled. 비활성화된 플러그인이 없습니다. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally 안전 모드가 켜져 있습니다. VectorCraft가 정상적으로 시작할 때까지 플러그인을 불러오거나 실행하지 않습니다 +@msg give `id` or `file` (see plugin.safeMode) `id` 또는 `file`을 지정하세요(plugin.safeMode 참조) +@msg that plug-in isn't disabled (see plugin.safeMode) 해당 플러그인은 비활성화되어 있지 않습니다(plugin.safeMode 참조) +@msg Couldn't save the disabled plug-ins for the next launch 비활성화된 플러그인을 다음 실행을 위해 저장할 수 없습니다 +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode 모양새 확장: 선택 항목의 플러그인 효과는 안전 모드에서 확장할 수 없습니다 + +# Align to Pixel Grid + Align to Pixel Grid 픽셀 격자에 정렬 +@msg only paths and compound paths can be aligned to the pixel grid 패스와 컴파운드 패스만 픽셀 격자에 정렬할 수 있습니다 +@msg side is left or right, not `{s}` side는 left 또는 right입니다(`{s}` 아님) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 72d4ea21a..ddc5d2186 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -135,6 +135,7 @@ Report copied Rapport gekopieerd Line {line} Regel {line} Line {line}: {name}: {status} Regel {line}: {name}: {status} + Read Action Set File Actiesetbestand lezen Read Shortcut Set File Sneltoetsensetbestand lezen Import Matches Overeenkomsten importeren Remember Import Matches Importovereenkomsten onthouden @@ -186,6 +187,11 @@ Ungroup Groepering opheffen Lock Vergrendelen Selection Selectie +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Rechthoekselectie + Touching Geraakt + Fully Enclosed Volledig omsloten + Drag Direction Sleeprichting All Artwork Above Alle artwork erboven Other Layers Andere lagen Unlock All Alles ontgrendelen @@ -307,6 +313,13 @@ Apply Bullets Opsommingstekens toepassen Numbered List Genummerde lijst Apply Numbers Nummering toepassen + Bullets and Numbering… Opsommingstekens en nummering… + Numbers Nummers + Level: Niveau: + Bullet Character: Opsommingsteken: + Number Style: Nummerstijl: + Left Indent: Links inspringen: + First Line Indent: Eerste regel inspringen: Convert to Text Converteren naar tekst Filled Dot Gevulde stip Dash Streepje @@ -434,6 +447,17 @@ Inflate Opblazen Select objects, then pick an object type to give them a 3D effect. Selecteer objecten en kies dan een objecttype om ze een 3D-effect te geven. Extrude & Bevel… Extruderen en afschuinen… + 3D (Classic) 3D (klassiek) + Extrude & Bevel (Classic)… Extruderen en afschuinen (klassiek)… + Surface Oppervlak + Wireframe Draadmodel + No Shading Geen arcering + Plastic Plastic + Highlight Intensity Glansintensiteit + Highlight Size Glansgrootte + Blend Steps Overvloeistappen + Shading Color Arceringskleur + Draw Hidden Faces Verborgen vlakken tekenen Revolve… Draaien… Inflate… Opblazen… Materials… Materialen… @@ -636,6 +660,7 @@ Toolbars Werkbalken Advanced Geavanceerd Double Column Dubbele kolom + Keep Tools Beside the Canvas Gereedschappen naast het canvas Actions Acties Asset Export Assetexport Attributes Kenmerken @@ -643,6 +668,11 @@ Color Kleur Color Guide Kleurhulp Color Themes Kleurthema's + Color Wheel Kleurenwiel + Hue ring Tintring + Saturation and brightness Verzadiging en helderheid + Color field Kleurveld + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties CSS-eigenschappen Document Info Documentinfo Flattener Preview Afvlakvoorbeeld @@ -829,6 +859,9 @@ Stained Glass… Glas in lood… Texturizer… Texturizer… System Title Bar Systeemtitelbalk + VectorCraft Title Bar on Every Desktop VectorCraft-titelbalk op elk bureaublad + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Zolang de omgevingsvariabele VECTORCRAFT_TITLEBAR is ingesteld, kiest die de titelbalk. + System Title Bar takes precedence over this option while it is on. Zolang Systeemtitelbalk is ingeschakeld, heeft die voorrang op deze optie. Exit Afsluiten Distort Vervormen Diffuse Glow… Diffuse gloed… @@ -1185,7 +1218,7 @@ lighting Light Licht X: X: Y: Y: Drag to float the toolbar Sleep om de werkbalk zwevend te maken - Drag to the window's left edge to dock Sleep naar de linkerrand van het venster om te dokken + Drag to the window's left or right edge to dock Sleep naar de linker- of rechterrand van het venster om te dokken Crystallize Kristalliseren Add Anchor Point Tool Ankerpunt toevoegen Anchor Point Tool Ankerpunt-gereedschap @@ -1439,6 +1472,15 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Tate-chu-yoko-instellingen… Tate-chu-yoko Settings Tate-chu-yoko-instellingen + Composite Fonts… Samengestelde lettertypen… + Composite Fonts Samengestelde lettertypen + Export Composite Fonts Samengestelde lettertypen exporteren + Import Composite Fonts Samengestelde lettertypen importeren + Delete Font Lettertype verwijderen + Kanji Kanji + Kana Kana + Numerals Cijfers + Scale at Center Schalen vanuit het midden Up/Down: Omhoog/omlaag: Left/Right: Links/rechts: Underline Onderstrepen @@ -1715,6 +1757,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 @@ -2475,6 +2521,7 @@ digits Malayalam Malayalam Tile Range: Tegelbereik: tile {number} tegel {number} Tiles are numbered across, then down; leave the range empty for every tile. Tegels worden van links naar rechts en vervolgens van boven naar beneden genummerd; laat het bereik leeg voor alle tegels. + Copies per Sheet (as many as fit, in a grid) Kopieën per vel (zoveel als er passen, in een raster) Transverse (the page a quarter turn on the paper) Dwars (de pagina een kwartslag op het papier) Unprintable margin: Niet afdrukbare marge: {pages} in all, {count} per copy {pages} in totaal, {count} per exemplaar @@ -2545,6 +2592,10 @@ digits Malayalam Malayalam < 1 sec < 1 sec [Unnamed] [Naamloos] Amount: Hoeveelheid: + Anti-aliasing Method Anti-aliasingmethode +antiAlias Sharp Scherp + Crisp Helder + Strong Krachtig Anti-aliasing: Anti-aliasing: Artboard: Tekenbord: Color Table Kleurentabel @@ -2939,6 +2990,9 @@ theme Appearance Vormgeving Asset Export Settings Exportinstellingen voor assets Assets Assets Export Assets Assets exporteren + Export 3D Object 3D-object exporteren + 3D Export Format 3D-exportindeling + Export 3D Assets 3D-assets exporteren Remove Asset Asset verwijderen Rename Asset Asset hernoemen Fill Rule Vulregel @@ -3072,6 +3126,7 @@ theme Appearance Vormgeving Document JSON Document-JSON Duplicate Dupliceren Find Objects Objecten zoeken + Compare with Image Vergelijken met afbeelding Inspect Document Document inspecteren Inspect Object Object inspecteren Begin Undo Group Groep voor ongedaan maken beginnen @@ -3375,6 +3430,7 @@ theme Appearance Vormgeving Feet Voet Files Bestanden Fill New Type Objects With Placeholder Text Nieuwe tekstobjecten met plaatshoudertekst vullen + Start Lists From Typed Markers Lijsten starten met getypte opsommingstekens Folder Map Font Preview Size Grootte van lettertypevoorbeelden Get Preferences Voorkeuren ophalen @@ -3485,6 +3541,7 @@ theme Appearance Vormgeving Yards Yards Zoom to Selection Inzoomen op selectie Zoom with Mouse Wheel Zoomen met muiswiel + Scroll Sideways with Cmd/Ctrl + Wheel Horizontaal scrollen met Cmd/Ctrl + muiswiel Scrub Numeric Fields by Dragging Numerieke velden aanpassen door te slepen Linux Display Server Linux-weergaveserver Print Preview Afdrukvoorbeeld @@ -3764,6 +3821,7 @@ effect Regular Regelmatig Find in Folder… Zoeken in map… Replace With Font Vervangen door lettertype Replace With Font From Vervangen door lettertype uit + Find In Zoeken in System Systeem — substituted by {style} — vervangen door {style} — {n} characters from another font — {n} tekens van een ander lettertype @@ -4007,7 +4065,7 @@ imageTrace Outline Contour @plural {n} stroke|{n} strokes {n} lijn|{n} lijnen @msg no library `{id}` geen bibliotheek `{id}` @msg select some type first selecteer eerst wat tekst -@msg kerning must be a number, "auto", or "optical" Kerning moet een getal, ‘auto’ of ‘optical’ zijn +@msg kerning must be a number, "auto", or "optical" kerning moet een getal, ‘auto’ of ‘optical’ zijn @msg {_1} has no {_2} style {_1} heeft geen {_2}stijl @msg a layer couldn't be read een laag kon niet worden gelezen @msg it has more than one artboard het heeft meer dan één tekengebied @@ -4092,6 +4150,7 @@ imageTrace Outline Contour @msg Split Into Grid: select paths large enough for the grid In raster verdelen: selecteer paden die groot genoeg zijn voor het raster @msg Targa files are RGB: colorModel rgb Targa-bestanden zijn RGB: colorModel rgb @msg Text Wrap: select a wrap object Tekstomloop: selecteer een omloopobject +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` moet "none", "sharp", "crisp" of "strong" zijn @msg `aspect` must be a number (%) `aspect` moet een getal zijn (%) @msg `at` must be [x, y] `at` moet [x, y] zijn @msg `color` must be a colour `color` moet een kleur zijn @@ -4182,7 +4241,7 @@ imageTrace Outline Contour @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art of type @msg artboard {0} does not exist tekengebied {0} bestaat niet @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard bij deze schaal is de tekening {w} × {h} pt, meer dan het canvas kan bevatten: kies een kleinere schaal of pas de tekening aan op het tekengebied -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` as moet "vertical", "horizontal" of een hoek in graden zijn, niet `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis moet "vertical", "horizontal" of een hoek in graden zijn, niet `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" achtergrond {other}: transparant, wit, zwart of een kleur zoals "#ff8800" @msg bad color {v} onjuiste kleur {v} @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio moet een getal van 0,1 tot 10 zijn @@ -4191,9 +4250,9 @@ imageTrace Outline Contour @msg bad shortcut onjuiste sneltoets @msg bad stream length onjuiste streamlengte @msg bad trailer onjuiste trailer -@msg balance must be between 0 and 100 balans moet tussen 0 en 100 liggen +@msg balance must be between 0 and 100 balance moet tussen 0 en 100 liggen @msg batch step {_1} (`{id}`) failed: {e} batchstap {_1} (`{id}`) is mislukt: {e} -@msg brightness must be dark|mediumDark|mediumLight|light helderheid moet dark|mediumDark|mediumLight|light zijn +@msg brightness must be dark|mediumDark|mediumLight|light brightness moet dark|mediumDark|mediumLight|light zijn @msg can't encode the document: {0} kan het document niet coderen: {0} @msg can't parse shortcut `{c}` kan sneltoets `{c}` niet parseren @msg can't parse shortcut `{s}` for `{k}` kan sneltoets `{s}` voor `{k}` niet parseren @@ -4216,7 +4275,7 @@ imageTrace Outline Contour @msg compound paths can only contain paths samengestelde paden kunnen alleen paden bevatten @msg compressed (RLE) bitmaps can't be read yet gecomprimeerde (RLE) bitmaps kunnen nog niet gelezen worden @msg damaged beschadigd -@msg decimals must be {_1}–{_2} (got {_3}) decimalen moeten {_1}–{_2} zijn (kreeg {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals moet {_1}–{_2} zijn (kreeg {_3}) @msg divide into 1 to {MAX_PIECES} slices deel op in 1 tot {MAX_PIECES} slices @msg effect {k} is `{_1}`, not `{id}` effect {k} is `{_1}`, niet `{id}` @msg empty brush stroke lege penseelstreek @@ -4330,6 +4389,8 @@ imageTrace Outline Contour @msg no such action set actieset bestaat niet @msg the action sets changed; nothing was deleted de actiesets zijn gewijzigd; er is niets verwijderd @msg no action set to save geen actieset om op te slaan +@msg the action set file can't be read in full het actiesetbestand kan niet volledig worden gelezen +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}” geladen: {n} stap is weggelaten|„{name}” geladen: {n} stappen zijn weggelaten @msg not an action set file geen actiesetbestand @msg the action set file is larger than 8 MiB het actiesetbestand is groter dan 8 MiB @msg the action set file holds more than 1,000 actions het actiesetbestand bevat meer dan 1.000 acties @@ -4395,9 +4456,9 @@ imageTrace Outline Contour @msg replace: the object to replace was changed or deleted vervangen: het te vervangen object is gewijzigd of verwijderd @msg pattern missing patroon ontbreekt @msg patterns nested too deeply are filled with mid-grey patronen die te diep genest zijn, worden gevuld met middelgrijs -@msg percent must be above 0 and at most 6400, not {p} procent moet boven 0 en maximaal 6400 zijn, niet {p} +@msg percent must be above 0 and at most 6400, not {p} percent moet boven 0 en maximaal 6400 zijn, niet {p} @msg pick a colour, gradient or pattern to save as a swatch kies een kleur, verloop of patroon om als staal op te slaan -@msg plane must be left, right or ground vlak moet links, rechts of grond zijn +@msg plane must be left, right or ground plane moet left, right of ground zijn @msg plug-in ABI: {0} plug-in-ABI: {0} @msg plug-in exceeded its {0} plug-in overschreed zijn {0} @msg plug-in failed: {0} plug-in mislukt: {0} @@ -4416,8 +4477,8 @@ imageTrace Outline Contour @msg result would exceed the canvas (coordinates out of range) het resultaat zou het canvas overschrijden (coördinaten buiten bereik) @msg objects can't be nested more than 32 deep objecten kunnen niet dieper dan 32 niveaus worden genest @msg the SVG's elements are nested more than {0} deep de elementen van de SVG zijn dieper dan {0} niveaus genest -@msg scale must be a number from {_1} to {_2}, not {_3} schaal moet een getal van {_1} tot {_2} zijn, niet {_3} -@msg scale must be a number from {_1} to {_2}, not {s} schaal moet een getal van {_1} tot {_2} zijn, niet {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale moet een getal van {_1} tot {_2} zijn, niet {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale moet een getal van {_1} tot {_2} zijn, niet {s} @msg select (or target) objects whose appearance isn't basic selecteer (of richt je op) objecten waarvan de vormgeving niet basis is @msg select a Live Paint group selecteer een Live Paint-groep @msg select a blend selecteer een vermenging @@ -4657,10 +4718,14 @@ imageTrace Outline Contour @msg its objects are nested too deeply de objecten ervan zijn te diep genest @msg its section ending with `%{end}` doesn't end zijn sectie die op `%{end}` eindigt, eindigt niet @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) penselen die VectorCraft nog niet kan lezen, zijn weggelaten uit het deelvenster Penselen ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objecten met meerdere vullingen of lijnen, effecten of penselen zijn als hun getekende uiterlijk binnengekomen ({_1}): hun vormgeving is nog niet bewerkbaar +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objecten met meerdere vullingen of lijnen, lijnen met variabele breedte, effecten of penselen zijn als hun getekende uiterlijk binnengekomen ({_1}): hun vormgeving is nog niet bewerkbaar +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding de naam `{name}` is geen UTF-8 en is gelezen als {label}: als die er verkeerd uitziet, open het bestand dan met een andere legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding de naam `{name}` is geen UTF-8, en delen ervan zijn ook geen geldige {label}: open het bestand met een andere legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) meer namen die geen UTF-8 zijn, zijn gelezen als {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it verloop „{name}” is niet gedefinieerd in het bestand: er is een effen kleur voor gebruikt @msg an image VectorCraft can't read was left out een afbeelding die VectorCraft niet kan lezen, is weggelaten @msg an image whose data is cut short was left out een afbeelding waarvan de gegevens zijn afgekapt, is weggelaten +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers een ingesloten afbeelding van {_1} × {_2} pixels is verkleind tot {_3} × {_4} pixels om de lagen te behouden @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel gekoppelde afbeeldingen tonen het in het bestand opgeslagen voorbeeld met lage resolutie totdat hun bestanden zijn gevonden: koppel ze opnieuw in het paneel Koppelingen @msg linked type areas past VectorCraft's limits were left out gekoppelde tekstvlakken buiten de limieten van VectorCraft zijn weggelaten @msg type that changes writing direction inside a text object opens in the direction of its first character tekst die binnen een tekstobject van schrijfrichting verandert, wordt geopend in de richting van het eerste teken @@ -4696,6 +4761,7 @@ imageTrace Outline Contour @msg object ids are non-negative integers, not {v} object-id's zijn niet-negatieve gehele getallen, niet {v} @msg {key} must be an array of non-negative integer object ids {key} moet een array van object-id's zijn die niet-negatieve gehele getallen zijn @msg invalid object id {value}: expected a non-negative integer ongeldige object-id {value}: een niet-negatief geheel getal verwacht +@msg `{key}` entry {index} is {value}, not a non-negative integer object id item {index} van `{key}` is {value}, geen object-id (een niet-negatief geheel getal) @msg no such object id {value} er is geen object met id {value} @msg automation filesystem access is not granted: read authority is absent: {path} toegang tot het bestandssysteem voor automatisering is niet verleend: de leesbevoegdheid ontbreekt: {path} @msg automation filesystem access is not granted: write authority is absent: {path} toegang tot het bestandssysteem voor automatisering is niet verleend: de schrijfbevoegdheid ontbreekt: {path} @@ -4720,12 +4786,12 @@ imageTrace Outline Contour @msg the PSD file's image data can't be read: {e} de afbeeldingsgegevens van het PSD-bestand kunnen niet worden gelezen: {e} @msg Imported library {name} Bibliotheek {name} geïmporteerd @msg Invalid panel layout; restored the default dock Ongeldige paneelindeling; het standaarddok is hersteld -@msg action is required actie is vereist +@msg action is required action is vereist @msg anchor must name a registered panel anchor moet een geregistreerd paneel benoemen @msg before must name a panel before moet een paneel benoemen @msg docking action contains an unknown panel or an excessive split path dokactie bevat een onbekend paneel of een overmatig splitspad @msg group must be true or false group moet true of false zijn -@msg panel is not in a dock group paneel staat niet in een dockgroep +@msg panel is not in a dock group panel staat niet in een dockgroep @msg only a dock group above or below others collapses, not one beside another alleen een dockgroep boven of onder andere vouwt samen, niet een ernaast @msg icon must be true or false icon moet true of false zijn @msg icon only docks a panel in the icon column, with no anchor icon dockt een paneel alleen in de pictogramkolom, zonder anchor @@ -5647,6 +5713,11 @@ swatch Bright Violet Felviolet @msg too many library panels were opened in this session er zijn in deze sessie te veel bibliotheekpanelen geopend @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. De afbeelding is {_1} × {_2} pixels, meer dan {LARGE_TRACE_SIDE} aan één zijde: ze wordt getraceerd, maar langzamer. Een voorinstelling met minder detail of een kleinere kopie van de afbeelding traceert sneller. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). De afbeelding is getraceerd op {_1} × {_2} pixels, verkleind van {_3} × {_4} (maximaal {_5} megapixels). +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. De modus Plat logo gebruikt keepColors niet: hij traceert de kleuren die in logoColors staan, of de kleuren die hij vindt. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Er is niets getraceerd: geen van de kleuren die het traceren vond, komt overeen met keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Er is niets getraceerd: Kleur negeren laat elk gebied van de kleuren in keepColors weg. +@msg Nothing was traced: the colors that match keepColors produce no paths. Er is niets getraceerd: de kleuren die overeenkomen met keepColors leveren geen paden op. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: geen van de kleuren die het traceren vond, komt overeen met {c}, dus daarvan wordt niets getraceerd. @msg Flat Logo traced the image at {_1} × {_2} pixels, averaged down from {_3} × {_4}. De modus Plat logo heeft de afbeelding getraceerd op {_1} × {_2} pixels, door middeling verkleind van {_3} × {_4}. Artboard {n} of {total} Tekengebied {n} van {total} File {n} of {total} Bestand {n} van {total} @@ -5771,6 +5842,8 @@ axis Y Axis Y-as Install “{name}”, which asks for these permissions? „{name}” installeren, dat om deze machtigingen vraagt? ID: {id} ID: {id} Version: {version} Versie: {version} + Install “{name}” in place of the plug-in with the same ID? „{name}” installeren in plaats van de plug-in met dezelfde ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Dit bestand is niet de plug-in die eerder met deze ID was geïnstalleerd. Het krijgt de instellingen en cache die voor die ID bewaard zijn. File SHA-256: {hash} SHA-256 van het bestand: {hash} Read the objects it is given De objecten lezen die het krijgt Change, add and delete objects in the document Objecten in het document wijzigen, toevoegen en verwijderen @@ -5778,6 +5851,9 @@ axis Y Axis Y-as Change how the objects its effect is applied to are drawn Wijzigen hoe de objecten waarop het effect is toegepast worden getekend Write the document as a file in its own format Het document als bestand in een eigen indeling schrijven Show its progress, see Cancel and write log lines De voortgang tonen, een annulering zien en logregels schrijven + Keep settings of its own Eigen instellingen bewaren + Keep a cache of its own, which you can clear Een eigen cache bewaren, die u kunt legen + Clear Plug-in Cache Plug-incache legen # Actions panel: action sets (#3712). New Set… Nieuwe set… New Set Nieuwe set @@ -6159,6 +6235,8 @@ breakText Glyphs Glyphs File + Data Set Name Bestand + naam gegevensset Data Set Name Naam gegevensset @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` moet `fileNumber`, `fileDataSetName` of `dataSetName` zijn + Each data set saves over the same file, which keeps only the last data set. Elke gegevensset wordt over hetzelfde bestand opgeslagen, dat alleen de laatste gegevensset bewaart. +@msg save the document with Save As before Save and Close sla het document op met Opslaan als voordat u Opslaan en sluiten kiest @msg no free data set file name in destination folder Geen vrije bestandsnaam voor de gegevensset in de doelmap # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6278,9 +6356,20 @@ breakText Glyphs Glyphs Reset Symbol Instance Symboolinstantie opnieuw instellen @msg type must be static or dynamic, not {v} type moet static of dynamic zijn, niet {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` is een statisch symbool: stel eerst het symbooltype in op Dynamisch +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Van een pad, samengesteld pad of tekst die in een symboolinstantie is geselecteerd, kan alleen de effen kleur van vulling en lijn veranderen @msg path must be a list of child indices path moet een lijst met onderliggende indexen zijn @msg no object at path {shown} in the symbol's art geen object op pad {shown} in de illustratie van het symbool @msg give fill, stroke, opacity and/or blend geef fill, stroke, opacity en/of blend op +@msg {key} cannot be set on a guide {key} kan niet worden ingesteld op een hulplijn +@msg {key} cannot be set on a blend {key} kan niet worden ingesteld op een vermenging +@msg {key} cannot be set on an envelope {key} kan niet worden ingesteld op een envelop +@msg {key} cannot be set on a gradient mesh {key} kan niet worden ingesteld op een verloopraster +@msg {key} cannot be set on a Repeat {key} kan niet worden ingesteld op een herhaling +@msg {key} cannot be set on a placed document {key} kan niet worden ingesteld op een geplaatst document +@msg {key} cannot be set on an Intertwine group {key} kan niet worden ingesteld op een verstrengelde groep +@msg stroke cannot be set on a symbol instance stroke kan niet worden ingesteld op een symboolinstantie +@msg fill on a symbol instance must be a solid color or none fill van een symboolinstantie moet een egale kleur of none zijn +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} kan niet worden ingesteld op een afbeelding zonder het effect Converteren naar vorm of Object omtrekken @msg pass newName, registration, exportType, nineSlice and/or type geef newName, registration, exportType, nineSlice en/of type op Text Flow Tekstdoorloop By Rows Per rij @@ -6310,6 +6399,7 @@ breakText Glyphs Glyphs Create Swatch Legend Stalenlegenda maken Create Swatch Legend… Stalenlegenda maken… HEX HEX + Help Bar Helpbalk # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Teksthulpmiddelen Text Tweaker… Tekst bijstellen… @@ -6375,3 +6465,30 @@ 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 + +# Safe Mode + Safe Mode… Veilige modus… + Safe Mode Veilige modus + Help › Safe Mode… Help › Veilige modus… + Enable on Re-launch Inschakelen bij volgende start + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. De laatste start van VectorCraft stopte terwijl de plug-in {name} werd geladen. Deze is mogelijk niet de oorzaak. + Disable and Continue Uitschakelen en doorgaan + Start Normally Normaal starten + Start in Safe Mode Starten in veilige modus + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft is in veilige modus: er worden geen plug-ins geladen of uitgevoerd totdat het normaal start. + Disabled plug-ins Uitgeschakelde plug-ins + No plug-ins are disabled. Er zijn geen plug-ins uitgeschakeld. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Veilige modus is aan: plug-ins worden niet geladen of uitgevoerd totdat VectorCraft normaal start +@msg give `id` or `file` (see plugin.safeMode) geef `id` of `file` op (zie plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) die plug-in is niet uitgeschakeld (zie plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch De uitgeschakelde plug-ins konden niet worden opgeslagen voor de volgende start +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Vormgeving uitbreiden: een plug-in-effect in de selectie kan niet worden uitgebreid in veilige modus + +# Align to Pixel Grid + Align to Pixel Grid Uitlijnen op pixelraster +@msg side is left or right, not `{s}` side is left of right, niet `{s}` diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ff2b9bd6b..259cab122 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -152,6 +152,11 @@ Ungroup Rozgrupuj Lock Zablokuj Selection Zaznaczenie +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Zaznaczanie prostokątne + Touching Dotknięte + Fully Enclosed Całkowicie wewnątrz + Drag Direction Kierunek przeciągania All Artwork Above Całą kompozycję powyżej Other Layers Pozostałe warstwy Unlock All Odblokuj wszystko @@ -295,6 +300,13 @@ Apply Bullets Zastosuj punktory Numbered List Lista numerowana Apply Numbers Zastosuj numerację + Bullets and Numbering… Punktory i numeracja… + Numbers Numery + Level: Poziom: + Bullet Character: Znak punktora: + Number Style: Styl numeracji: + Left Indent: Wcięcie z lewej: + First Line Indent: Wcięcie pierwszego wiersza: Convert to Text Konwertuj na tekst Filled Dot Pełna kropka Dash Myślnik @@ -422,6 +434,17 @@ Inflate Nadmuchanie Select objects, then pick an object type to give them a 3D effect. Zaznacz obiekty, a następnie wybierz typ obiektu, aby nadać im efekt 3D. Extrude & Bevel… Wyciągnięcie i fazowanie… + 3D (Classic) 3D (klasyczne) + Extrude & Bevel (Classic)… Wyciągnięcie i fazowanie (klasyczne)… + Surface Powierzchnia + Wireframe Szkielet + No Shading Bez cieniowania + Plastic Plastik + Highlight Intensity Intensywność połysku + Highlight Size Rozmiar połysku + Blend Steps Kroki przejścia + Shading Color Kolor cieniowania + Draw Hidden Faces Rysuj ukryte ściany Revolve… Obrót… Inflate… Nadmuchanie… Materials… Materiały… @@ -623,6 +646,7 @@ Toolbars Paski narzędzi Advanced Zaawansowany Double Column Dwie kolumny + Keep Tools Beside the Canvas Narzędzia obok płótna Actions Operacje Asset Export Eksport zasobów Attributes Atrybuty @@ -630,6 +654,11 @@ Color Kolor Color Guide Przewodnik kolorów Color Themes Motywy kolorów + Color Wheel Koło kolorów + Hue ring Pierścień barwy + Saturation and brightness Nasycenie i jasność + Color field Pole koloru + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties Właściwości CSS Document Info Informacje o dokumencie Flattener Preview Podgląd spłaszczania @@ -830,6 +859,9 @@ Stained Glass… Witraż… Texturizer… Teksturowanie… System Title Bar Systemowy pasek tytułu + VectorCraft Title Bar on Every Desktop Pasek tytułu VectorCraft na każdym pulpicie + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Dopóki zmienna środowiskowa VECTORCRAFT_TITLEBAR jest ustawiona, to ona wybiera pasek tytułu. + System Title Bar takes precedence over this option while it is on. Dopóki Systemowy pasek tytułu jest włączony, ma pierwszeństwo przed tą opcją. Exit Zakończ Distort Zniekształcenie Diffuse Glow… Rozproszony blask… @@ -1185,7 +1217,7 @@ lighting Light Światło Toggle single/double column Przełącz jedną/dwie kolumny W: S: Drag to float the toolbar Przeciągnij, aby odpiąć pasek narzędzi - Drag to the window's left edge to dock Przeciągnij do lewej krawędzi okna, aby zadokować + Drag to the window's left or right edge to dock Przeciągnij do lewej lub prawej krawędzi okna, aby zadokować Crystallize Krystalizacja Add Anchor Point Tool Narzędzie Dodaj punkt kontrolny Anchor Point Tool Narzędzie Punkt kontrolny @@ -1428,6 +1460,15 @@ digits Malayalam Malajalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Ustawienia Tate-chu-yoko… Tate-chu-yoko Settings Ustawienia Tate-chu-yoko + Composite Fonts… Fonty złożone… + Composite Fonts Fonty złożone + Export Composite Fonts Eksportuj fonty złożone + Import Composite Fonts Importuj fonty złożone + Delete Font Usuń font + Kanji Kanji + Kana Kana + Numerals Cyfry + Scale at Center Skaluj od środka Up/Down: W górę/w dół: Left/Right: W lewo/w prawo: Underline Podkreślenie @@ -1814,6 +1855,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 @@ -2587,6 +2632,8 @@ digits Malayalam Malajalam Tile Range: Zakres kafelków: tile {number} kafelek {number} Tiles are numbered across, then down; leave the range empty for every tile. Kafelki są numerowane poziomo, a potem w dół; pozostaw zakres pusty dla wszystkich kafelków. + Copies per Sheet (as many as fit, in a grid) Kopie na arkusz (tyle, ile się zmieści, w siatce) + Gutter: Odstęp: Transverse (the page a quarter turn on the paper) Poprzecznie (strona obrócona o ćwierć obrotu na papierze) Unprintable margin: Margines niedrukowalny: {pages} in all, {count} per copy Razem: {pages}, na kopię: {count} @@ -2652,6 +2699,10 @@ digits Malayalam Malajalam < 1 sec < 1 s [Unnamed] [Bez nazwy] Amount: Wartość: + Anti-aliasing Method Metoda wygładzania +antiAlias Sharp Ostre + Crisp Wyraźne + Strong Mocne Anti-aliasing: Wygładzanie: Artboard: Obszar kompozycji: Color Table Tablica kolorów @@ -3060,6 +3111,14 @@ theme Appearance Wygląd Asset Export Settings Ustawienia eksportu zasobów Assets Zasoby Export Assets Eksportuj zasoby + Export 3D Object Eksportuj obiekt 3D + 3D Export Format Format eksportu 3D + Export 3D Assets Eksportuj zasoby 3D +@msg {what} can't be exported in 3D: {e} nie można wyeksportować {what} w 3D: {e} +@msg asset {id} isn't a 3D asset zasób {id} nie jest zasobem 3D +@msg asset {id} is a 3D asset: assets.export3D writes it zasób {id} jest zasobem 3D: zapisuje go assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) brak zasobów 3D: najpierw zbierz obiekt z efektem 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) zaznacz obiekt z efektem 3D (lub podaj jego identyfikatory) Remove Asset Usuń zasób Rename Asset Zmień nazwę zasobu Fill Rule Reguła wypełniania @@ -3195,6 +3254,7 @@ theme Appearance Wygląd Document JSON JSON dokumentu Duplicate Powiel Find Objects Znajdź obiekty + Compare with Image Porównaj z obrazem Inspect Document Zbadaj dokument Inspect Object Zbadaj obiekt Begin Undo Group Rozpocznij grupę cofania @@ -3496,6 +3556,7 @@ theme Appearance Wygląd Feet Stopy Files Pliki Fill New Type Objects With Placeholder Text Wypełniaj nowe obiekty tekstowe tekstem zastępczym + Start Lists From Typed Markers Rozpoczynaj listy od wpisanych znaczników Folder Folder Font Preview Size Rozmiar podglądu czcionki Get Preferences Pobierz preferencje @@ -3606,6 +3667,7 @@ theme Appearance Wygląd Yards Jardy Zoom to Selection Powiększaj do zaznaczenia Zoom with Mouse Wheel Powiększaj kółkiem myszy + Scroll Sideways with Cmd/Ctrl + Wheel Przewijaj w poziomie kółkiem z Cmd/Ctrl Scrub Numeric Fields by Dragging Zmieniaj pola liczbowe przeciąganiem Linux Display Server Serwer wyświetlania Linux Print Preview Podgląd wydruku @@ -3869,6 +3931,7 @@ effect Regular Normalny Find in Folder… Znajdź w folderze… Replace With Font Zastąp czcionką Replace With Font From Zastąp czcionką z + Find In Szukaj w System System — substituted by {style} — zastąpiona przez {style} — {n} characters from another font — znaki z innej czcionki: {n} @@ -4101,7 +4164,7 @@ imageTrace Outline Kontur @plural {n} stroke|{n} strokes {n} obrys|{n} obrysy|{n} obrysów @msg no library `{id}` brak biblioteki `{id}` @msg select some type first najpierw zaznacz tekst -@msg kerning must be a number, "auto", or "optical" Kerning musi być liczbą, „auto” lub „optical” +@msg kerning must be a number, "auto", or "optical" kerning musi być liczbą, „auto” lub „optical” @msg the selection has no gradient fill zaznaczenie nie ma gradientu wypełnienia @msg the selection has no gradient stroke zaznaczenie nie ma gradientu obrysu @msg the selection has no pattern fill zaznaczenie nie ma wzorka wypełnienia @@ -4204,6 +4267,7 @@ imageTrace Outline Kontur @msg Split Into Grid: select paths large enough for the grid Podziel na siatkę: zaznacz ścieżki wystarczająco duże dla siatki @msg Targa files are RGB: colorModel rgb Pliki Targa są w RGB: colorModel rgb @msg Text Wrap: select a wrap object Oblewanie tekstem: zaznacz obiekt oblewany +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` musi mieć wartość "none", "sharp", "crisp" lub "strong" @msg `aspect` must be a number (%) `aspect` musi być liczbą (%) @msg `at` must be [x, y] `at` musi mieć postać [x, y] @msg `color` must be a colour `color` musi być kolorem @@ -4313,7 +4377,7 @@ imageTrace Outline Kontur @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art lub type @msg artboard {0} does not exist obszar kompozycji {0} nie istnieje @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard w tej skali rysunek ma {w} × {h} pt, więcej niż mieści płótno: wybierz mniejszą skalę lub dopasuj go do obszaru kompozycji -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` oś musi mieć wartość "vertical", "horizontal" lub kąt w stopniach, nie `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis musi mieć wartość "vertical", "horizontal" lub kąt w stopniach, nie `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" tło {other}: transparent, white, black lub kolor, np. "#ff8800" @msg bad color {v} nieprawidłowy kolor {v} @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio musi być liczbą od 0,1 do 10 @@ -4327,9 +4391,9 @@ imageTrace Outline Kontur @msg bad shortcut nieprawidłowy skrót @msg bad stream length nieprawidłowa długość strumienia @msg bad trailer nieprawidłowa sekcja trailer -@msg balance must be between 0 and 100 równowaga musi mieścić się w zakresie od 0 do 100 +@msg balance must be between 0 and 100 balance musi mieścić się w zakresie od 0 do 100 @msg batch step {_1} (`{id}`) failed: {e} krok wsadowy {_1} (`{id}`) nie powiódł się: {e} -@msg brightness must be dark|mediumDark|mediumLight|light jasność musi mieć wartość dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light brightness musi mieć wartość dark|mediumDark|mediumLight|light @msg can't encode the document: {0} nie można zakodować dokumentu: {0} @msg can't parse shortcut `{c}` nie można przeanalizować skrótu `{c}` @msg can't parse shortcut `{s}` for `{k}` nie można przeanalizować skrótu `{s}` dla `{k}` @@ -4354,7 +4418,7 @@ imageTrace Outline Kontur @msg compound paths can only contain paths ścieżki złożone mogą zawierać tylko ścieżki @msg compressed (RLE) bitmaps can't be read yet skompresowanych bitmap (RLE) nie można jeszcze odczytać @msg damaged uszkodzony -@msg decimals must be {_1}–{_2} (got {_3}) liczba miejsc dziesiętnych musi wynosić {_1}–{_2} (podano {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals musi wynosić {_1}–{_2} (podano {_3}) @msg divide into 1 to {MAX_PIECES} slices podziel na od 1 do {MAX_PIECES} plasterków @msg effect {k} is `{_1}`, not `{id}` efekt {k} to `{_1}`, nie `{id}` @msg empty brush stroke puste pociągnięcie pędzla @@ -4372,9 +4436,9 @@ imageTrace Outline Kontur @msg an image has no pixels and was left out pominięto obraz bez pikseli @msg image data `{_1}` is missing brak danych obrazu `{_1}` @msg image would be too large; lower the resolution obraz byłby zbyt duży; zmniejsz rozdzielczość -@msg index must be a number or first, previous, next or last indeks musi być liczbą lub first, previous, next albo last -@msg index must be a number or first, previous, next or last, not `{other}` indeks musi być liczbą lub first, previous, next albo last, nie `{other}` -@msg index must be ≥ 0 indeks nie może być ujemny +@msg index must be a number or first, previous, next or last index musi być liczbą lub first, previous, next albo last +@msg index must be a number or first, previous, next or last, not `{other}` index musi być liczbą lub first, previous, next albo last, nie `{other}` +@msg index must be ≥ 0 index musi być ≥ 0 @msg installing from a path is not available on the web; pass the module as `dataBase64` instalacja ze ścieżki nie jest dostępna w wersji webowej; przekaż moduł jako `dataBase64` @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) błąd wewnętrzny w `{cmd}`: {msg} (dokument pozostał bez zmian; zgłoś ten błąd) @msg internal error: {msg} (please report this bug) błąd wewnętrzny: {msg} (zgłoś ten błąd) @@ -4479,6 +4543,8 @@ imageTrace Outline Kontur @msg no such action set brak takiego zestawu operacji @msg the action sets changed; nothing was deleted zestawy operacji się zmieniły; nic nie usunięto @msg no action set to save brak zestawu operacji do zapisania +@msg the action set file can't be read in full nie można odczytać całego pliku zestawu operacji +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Wczytano „{name}”: pominięto {n} krok|Wczytano „{name}”: pominięto {n} kroki|Wczytano „{name}”: pominięto {n} kroków @msg not an action set file to nie jest plik zestawu operacji @msg the action set file is larger than 8 MiB plik zestawu operacji jest większy niż 8 MiB @msg the action set file holds more than 1,000 actions plik zestawu operacji zawiera ponad 1000 operacji @@ -4554,9 +4620,9 @@ imageTrace Outline Kontur @msg replace: the object to replace was changed or deleted replace: obiekt do zastąpienia został zmieniony lub usunięty @msg pattern missing brak wzorka @msg patterns nested too deeply are filled with mid-grey zbyt głęboko zagnieżdżone wzorki są wypełniane średnią szarością -@msg percent must be above 0 and at most 6400, not {p} procent musi być większy od 0 i nie większy niż 6400, nie {p} +@msg percent must be above 0 and at most 6400, not {p} percent musi być większy od 0 i nie większy niż 6400, nie {p} @msg pick a colour, gradient or pattern to save as a swatch wybierz kolor, gradient lub wzorek do zapisania jako próbka -@msg plane must be left, right or ground płaszczyzna musi mieć wartość left, right lub ground +@msg plane must be left, right or ground plane musi mieć wartość left, right lub ground @msg plug-in ABI: {0} ABI wtyczki: {0} @msg plug-in exceeded its {0} wtyczka przekroczyła swój limit: {0} @msg plug-in failed: {0} wtyczka zawiodła: {0} @@ -4575,8 +4641,8 @@ imageTrace Outline Kontur @msg result would exceed the canvas (coordinates out of range) wynik wykroczyłby poza płótno (współrzędne poza zakresem) @msg objects can't be nested more than 32 deep obiektów nie można zagnieżdżać głębiej niż na 32 poziomy @msg the SVG's elements are nested more than {0} deep elementy SVG są zagnieżdżone głębiej niż na {0} poziomów -@msg scale must be a number from {_1} to {_2}, not {_3} skala musi być liczbą od {_1} do {_2}, nie {_3} -@msg scale must be a number from {_1} to {_2}, not {s} skala musi być liczbą od {_1} do {_2}, nie {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale musi być liczbą od {_1} do {_2}, nie {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale musi być liczbą od {_1} do {_2}, nie {s} @msg select (or target) objects whose appearance isn't basic zaznacz (lub wskaż) obiekty, których wygląd nie jest podstawowy @msg select a Live Paint group zaznacz grupę aktywnego malowania @msg select a blend zaznacz przejście @@ -4605,6 +4671,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 @@ -4842,11 +4915,15 @@ imageTrace Outline Kontur @msg its objects are nested too deeply jego obiekty są zagnieżdżone zbyt głęboko @msg its section ending with `%{end}` doesn't end jego sekcja kończąca się na `%{end}` nie ma końca @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) pędzle, których VectorCraft jeszcze nie odczytuje, pominięto w panelu Pędzle ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet obiekty z kilkoma wypełnieniami lub obrysami, efektami albo pędzlami wczytano jako ich narysowany wygląd ({_1}): ich wyglądu nie można jeszcze edytować +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet obiekty z kilkoma wypełnieniami lub obrysami, obrysami o zmiennej szerokości, efektami albo pędzlami wczytano jako ich narysowany wygląd ({_1}): ich wyglądu nie można jeszcze edytować +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding nazwa `{name}` nie jest w UTF-8 i została odczytana jako {label}: jeśli wygląda źle, otwórz plik z innym legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding nazwa `{name}` nie jest w UTF-8, a jej części nie są poprawne również w {label}: otwórz plik z innym legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) kolejne nazwy spoza UTF-8 odczytano jako {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradient „{name}” nie jest zdefiniowany w pliku: zastąpiono go jednolitym kolorem @msg an image VectorCraft can't read was left out pominięto obraz, którego VectorCraft nie potrafi odczytać @msg graphs come in as plain art, without their data or settings wykresy są wczytywane jako zwykła grafika, bez ich danych i ustawień @msg an image whose data is cut short was left out pominięto obraz z uciętymi danymi +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers osadzony obraz o wymiarach {_1} × {_2} pikseli zmniejszono do {_3} × {_4} pikseli, aby zachować warstwy @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel połączone obrazy pokazują zapisany w pliku podgląd niskiej rozdzielczości, dopóki ich pliki nie zostaną znalezione: połącz je ponownie w panelu Łącza @msg linked type areas past VectorCraft's limits were left out połączone obszary tekstu poza limitami VectorCraft zostały pominięte @msg type that changes writing direction inside a text object opens in the direction of its first character tekst zmieniający kierunek pisania wewnątrz obiektu tekstowego otwiera się w kierunku swojego pierwszego znaku @@ -4882,6 +4959,7 @@ imageTrace Outline Kontur @msg object ids are non-negative integers, not {v} identyfikatory obiektów to nieujemne liczby całkowite, nie {v} @msg {key} must be an array of non-negative integer object ids {key} musi być tablicą identyfikatorów obiektów, które są nieujemnymi liczbami całkowitymi @msg invalid object id {value}: expected a non-negative integer nieprawidłowy identyfikator obiektu {value}: oczekiwano nieujemnej liczby całkowitej +@msg `{key}` entry {index} is {value}, not a non-negative integer object id element {index} w `{key}` to {value}, a nie identyfikator obiektu (nieujemna liczba całkowita) @msg no such object id {value} brak obiektu o identyfikatorze {value} @msg id {value} is a layer, not an object identyfikator {value} oznacza warstwę, a nie obiekt @msg id {value} is a layer, not an object; move layers with layer.move identyfikator {value} oznacza warstwę, a nie obiekt; warstwy przenosi się za pomocą layer.move @@ -4908,12 +4986,19 @@ imageTrace Outline Kontur @msg the Pixar file's image data is damaged dane obrazu pliku Pixar są uszkodzone @msg the JPEG 2000 file can't be read nie można odczytać pliku JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content plik JPEG 2000 zawiera nieobsługiwaną przestrzeń barw lub zawartość JPX +@msg the HEIC or HEIF file can't be read nie można odczytać pliku HEIC lub HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read plik HEIC lub HEIF zawiera rodzaj obrazu, którego nie można odczytać +@msg HEIC and HEIF images can't be opened in this build obrazów HEIC i HEIF nie można otworzyć w tej wersji @msg the PSD image is too large to read ({_1} × {_2} pixels) obraz PSD jest zbyt duży, aby go odczytać ({_1} × {_2} pikseli) @msg zipped 32-bit PSD image data with prediction can't be read nie można odczytać spakowanych 32-bitowych danych obrazu PSD z predykcją @msg {depth}-bit PSD files in this colour mode can't be read nie można odczytać {depth}-bitowych plików PSD w tym trybie koloru @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 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} @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 @@ -5045,6 +5130,7 @@ imageTrace Outline Kontur Report copied Skopiowano raport Line {line} Wiersz {line} Line {line}: {name}: {status} Wiersz {line}: {name}: {status} + Read Action Set File Odczytaj plik zestawu operacji Read Shortcut Set File Odczytaj plik zestawu skrótów Import Matches Dopasowania importu Remember Import Matches Zapamiętaj dopasowania importu @@ -5350,6 +5436,11 @@ mark-style Japanese Japońskie @msg unknown parameter `{key}`; the command takes: {keys} nieznany parametr `{key}`; polecenie przyjmuje: {keys} Load EPS into Clipboard Wczytaj EPS do schowka @msg an image of {w} × {h} pixels is too large to draw obraz o wymiarach {w} × {h} pikseli jest zbyt duży, aby go narysować +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: to nie jest obraz PNG, JPEG, WebP, TIFF ani BMP +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ obraz ma {iw} × {ih} pikseli, a obszar kompozycji przy tej szerokości ma {rw} × {rh} pikseli: ich proporcje się różnią +@msg `threshold` must be a number from 0 to 1 `threshold` musi być liczbą od 0 do 1 +@msg background {v}: a color such as "#808080" tło {v}: kolor, np. "#808080" +@msg comparing with an image file isn't available in the browser porównywanie z plikiem obrazu nie jest dostępne w przeglądarce @msg an image of {w} × {h} pixels is shown at reduced size obraz o wymiarach {w} × {h} pikseli jest wyświetlany w zmniejszonym rozmiarze Basic Podstawowy Fill and Stroke Controls Elementy sterujące wypełnieniem i obrysem @@ -5558,11 +5649,19 @@ mark-style Japanese Japońskie @msg too many library panels were opened in this session w tej sesji otwarto zbyt wiele paneli bibliotek @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Obraz ma {_1} × {_2} pikseli, ponad {LARGE_TRACE_SIDE} na jednym boku: zostanie obrysowany, ale wolniej. Ustawienie wstępne o niższej wierności lub mniejsza kopia obrazu obrysuje się szybciej. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Obraz obrysowano w rozdzielczości {_1} × {_2} pikseli, po zmniejszeniu z {_3} × {_4} (najwyżej {_5} megapikseli). +@msg byColor must be true or false byColor musi mieć wartość true lub false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor nie może podzielić grup Automatycznego grupowania: obrysuj bez Automatycznego grupowania +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor nie może ponownie zgrupować obrysu z aktywnym efektem Pathfinder: rozwiń bez byColor Snapping Przyciąganie Snap to Tangent Przyciągaj do stycznych Snap to Perpendicular Przyciągaj do prostopadłych @msg unknown snap option `{key}` (use {options}) nieznana opcja przyciągania `{key}` (użyj: {options}) Gap Length: Długość przerwy: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Tryb Płaskie logo nie używa keepColors: obrysowuje kolory podane w logoColors lub kolory, które znajdzie. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Nic nie zostało obrysowane: żaden z kolorów znalezionych podczas obrysowania nie pasuje do keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Nic nie zostało obrysowane: Ignoruj kolor pomija każdy obszar kolorów wymienionych w keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Nic nie zostało obrysowane: kolory pasujące do keepColors nie dają żadnych ścieżek. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: żaden z kolorów znalezionych podczas obrysowania nie pasuje do {c}, więc nic z niego nie jest obrysowywane. @msg `colorGroup` needs `library: "document"` `colorGroup` wymaga `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` wymaga `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` wymaga `mode: "color"` @@ -5674,11 +5773,14 @@ axis Y Axis Oś Y @msg Running “{name}”… Uruchamianie „{name}”… @msg a live effect runs while the document is drawn, so it can't be `heavy` efekt na żywo działa podczas rysowania dokumentu, więc nie może być `heavy` @msg an exporter always gets an export's budget: leave `budget` out wtyczka eksportu zawsze dostaje budżet eksportu: pomiń `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}” prosi o duży budżet obliczeń (każde uruchomienie do {minutes} minut i {times} razy więcej instrukcji niż zwykle): zainstaluj wtyczkę z `allowHeavy: true`, gdy użytkownik się zgodzi +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}” prosi o duży budżet obliczeń (każde uruchomienie do {minutes} minut i {times} razy więcej instrukcji niż zwykle): musi ją zatwierdzić osoba w aplikacji desktopowej albo przekaż `allowHeavy: true` w sesji uruchomionej z --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Zainstalować „{name}”, wtyczkę, która prosi o te uprawnienia? ID: {id} ID: {id} Version: {version} Wersja: {version} + Install “{name}” in place of the plug-in with the same ID? Zainstalować „{name}” zamiast wtyczki o tym samym ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ten plik nie jest wtyczką zainstalowaną wcześniej z tym ID. Otrzyma ustawienia i pamięć podręczną przechowywane dla tego ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}” to inny moduł niż wtyczka o ID {_1} i otrzymałby przechowywane dla niej ustawienia i pamięć podręczną: zainstaluj go z `replace: true`, gdy użytkownik się zgodzi File SHA-256: {hash} SHA-256 pliku: {hash} Read the objects it is given Odczytywać obiekty, które otrzymuje Change, add and delete objects in the document Zmieniać, dodawać i usuwać obiekty w dokumencie @@ -5686,21 +5788,43 @@ 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 Show its progress, see Cancel and write log lines Pokazywać swój postęp, wykrywać anulowanie i zapisywać wiersze dziennika -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} uprawnienie `progress` wymaga poziomu API wtyczek {PROGRESS_API}: podaj `api` {PROGRESS_API} + Keep settings of its own Przechowywać własne ustawienia + Keep a cache of its own, which you can clear Przechowywać własną pamięć podręczną, którą możesz wyczyścić + Clear Plug-in Cache Wyczyść pamięć podręczną wtyczki +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} uprawnienie `{permission}` wymaga poziomu API wtyczek {PROGRESS_API}: podaj `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} moduł importuje `env.{name}` z innym typem niż typ hosta: typ hosta to {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` moduł importuje `env.{name}`, którego wtyczka może używać tylko na poziomie API wtyczek {PROGRESS_API} (`api` {PROGRESS_API}) i z uprawnieniem `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network moduł importuje `{from}.{name}`, którego host nie udostępnia: wtyczka może importować tylko `env.vc_progress`, `env.vc_cancelled` i `env.vc_log` (poziom API wtyczek 2), bez WASI, plików i sieci +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` moduł importuje `env.{name}`, którego wtyczka może używać tylko na poziomie API wtyczek {PROGRESS_API} (`api` {PROGRESS_API}) i z uprawnieniem `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network moduł importuje `{from}.{name}`, którego host nie udostępnia: wtyczka może importować tylko funkcje `env.vc_*` wymienione w docs/plugins.md (poziom API wtyczek 2), bez WASI, plików i sieci @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` may only be true `paintOutput` może mieć tylko wartość true +@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}` @msg at most {MAX_PERMISSIONS} `permissions` najwyżej {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` uprawnienie `{shown}` nie dotyczy wtyczki rodzaju `{_1}` @msg permission `{shown}` is listed twice uprawnienie `{shown}` występuje dwa razy +@msg only a filter declares `inputs` tylko filtr deklaruje `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` wymienia nieznany rodzaj obiektu `{shown}`: użyj type, image lub symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` wymienia `{shown}` dwa razy +@msg `inputs` needs `api` 2 or later `inputs` wymaga `api` 2 lub nowszego +@msg `inputs` needs the permission `doc.read` `inputs` wymaga uprawnienia `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) budżet pamięci (obiekt {_1} wymaga {size} bajtów danych wejściowych, pozostało {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) budżet obiektów (ponad {MAX_OBJECTS} obiektów tylko do odczytu) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it uprawnienie `{shown}` jest zarezerwowane dla późniejszego API wtyczek; ten host go nie przyznaje @msg unknown permission `{shown}` nieznane uprawnienie `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}” prosi o uprawnienia {asked}: zainstaluj ją z `grant`, który je wymienia, gdy użytkownik się zgodzi +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants „{name}” prosi o uprawnienia {asked}: musi je zatwierdzić osoba w aplikacji desktopowej albo przekaż `grant` w sesji uruchomionej z --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` i `allowHeavy` działają tylko w sesji uruchomionej z --allow-plugin-grants: zatwierdź wtyczkę w aplikacji desktopowej +@msg Only the person at the keyboard can approve installing a plug-in Instalację wtyczki może zatwierdzić tylko osoba przy klawiaturze @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}” nie otrzymała `{p}`, którego potrzebuje jej wynik: nic nie zmieniono +@msg a heavy filter runs in the background, so it can't declare `commands` ciężki filtr działa w tle, więc nie może deklarować `commands` +@msg at most {MAX_COMMANDS} `commands` najwyżej {MAX_COMMANDS} `commands` +@msg command {name} is declared twice polecenie {name} jest zadeklarowane dwa razy +@msg command {name}: `label` must be 1-64 printable characters polecenie {name}: `label` musi mieć 1-64 drukowalnych znaków +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter polecenie {name}: użyj id z 1-{MAX_COMMAND_ID} znaków spośród a-z 0-9 _, zaczynającego się od litery +@msg command {name}: {e} polecenie {name}: {e} +@msg only a filter or a live effect declares `commands` `commands` deklaruje tylko filtr lub efekt na żywo +@msg “{name}” › {label}: {e} „{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed moduł nie ma skrótu SHA-256 podanego w `hash`: nic nie zainstalowano # Actions panel: action sets (#3712). New Set… Nowy zestaw… @@ -6119,6 +6243,8 @@ breakText Glyphs Glify File + Data Set Name Plik + nazwa zestawu danych Data Set Name Nazwa zestawu danych @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` musi mieć wartość `fileNumber`, `fileDataSetName` lub `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Każdy zestaw danych jest zapisywany w tym samym pliku, który zachowuje tylko ostatni zestaw danych. +@msg save the document with Save As before Save and Close zapisz dokument poleceniem „Zapisz jako” przed „Zapisz i zamknij” @msg no free data set file name in destination folder Brak wolnej nazwy pliku zestawu danych w folderze docelowym # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6239,9 +6365,20 @@ breakText Glyphs Glify Reset Symbol Instance Resetuj wystąpienie symbolu @msg type must be static or dynamic, not {v} type musi mieć wartość static lub dynamic, a nie {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` jest symbolem statycznym: najpierw ustaw jego typ symbolu na Dynamiczny +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change W ścieżce, ścieżce złożonej lub tekście zaznaczonym w wystąpieniu symbolu można zmienić tylko jednolity kolor wypełnienia i obrysu @msg path must be a list of child indices path musi być listą indeksów elementów podrzędnych @msg no object at path {shown} in the symbol's art brak obiektu w ścieżce {shown} w grafice symbolu @msg give fill, stroke, opacity and/or blend podaj fill, stroke, opacity i/lub blend +@msg {key} cannot be set on a guide nie można ustawić {key} dla linii pomocniczej +@msg {key} cannot be set on a blend nie można ustawić {key} dla przejścia +@msg {key} cannot be set on an envelope nie można ustawić {key} dla koperty +@msg {key} cannot be set on a gradient mesh nie można ustawić {key} dla siatki gradientowej +@msg {key} cannot be set on a Repeat nie można ustawić {key} dla powtórzenia +@msg {key} cannot be set on a placed document nie można ustawić {key} dla umieszczonego dokumentu +@msg {key} cannot be set on an Intertwine group nie można ustawić {key} dla grupy przeplatania +@msg stroke cannot be set on a symbol instance nie można ustawić stroke dla wystąpienia symbolu +@msg fill on a symbol instance must be a solid color or none fill wystąpienia symbolu musi być jednolitym kolorem lub none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it nie można ustawić {key} dla obrazu bez efektu Konwertuj na kształt lub Kontur obiektu @msg pass newName, registration, exportType, nineSlice and/or type podaj newName, registration, exportType, nineSlice i/lub type Text Flow Przepływ tekstu By Rows Wierszami @@ -6271,6 +6408,7 @@ breakText Glyphs Glify Create Swatch Legend Utwórz legendę próbek Create Swatch Legend… Utwórz legendę próbek… HEX HEX + Help Bar Pasek pomocy # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Narzędzia tekstu Text Tweaker… Strojenie tekstu… @@ -6336,3 +6474,44 @@ 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 plug-in of kind `{_2}`, not an importer `{_1}` to wtyczka rodzaju `{_2}`, a nie wtyczka importu +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` to wtyczka rodzaju `{_2}`, a nie wtyczka eksportu +@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` + +# Safe Mode + Safe Mode… Tryb awaryjny… + Safe Mode Tryb awaryjny + Help › Safe Mode… Pomoc › Tryb awaryjny… + Enable on Re-launch Włącz przy następnym uruchomieniu + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Ostatnie uruchomienie programu VectorCraft zatrzymało się podczas wczytywania wtyczki {name}. Może ona nie być przyczyną. + Disable and Continue Wyłącz i kontynuuj + Start Normally Uruchom normalnie + Start in Safe Mode Uruchom w trybie awaryjnym + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft działa w trybie awaryjnym: żadne wtyczki nie są wczytywane ani uruchamiane, dopóki program nie zostanie uruchomiony normalnie. + Disabled plug-ins Wyłączone wtyczki + No plug-ins are disabled. Żadna wtyczka nie jest wyłączona. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Tryb awaryjny jest włączony: wtyczki nie są wczytywane ani uruchamiane, dopóki VectorCraft nie zostanie uruchomiony normalnie +@msg give `id` or `file` (see plugin.safeMode) podaj `id` lub `file` (patrz plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) ta wtyczka nie jest wyłączona (patrz plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch Nie można zapisać wyłączonych wtyczek na następne uruchomienie +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Rozszerz wygląd: efektu wtyczki w zaznaczeniu nie można rozszerzyć w trybie awaryjnym + +# Align to Pixel Grid + Align to Pixel Grid Wyrównaj do siatki pikseli +@msg only paths and compound paths can be aligned to the pixel grid do siatki pikseli można wyrównać tylko ścieżki i ścieżki złożone +@msg side is left or right, not `{s}` side to left lub right, a nie `{s}` diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3340597d8..b5371fd5b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -198,6 +198,10 @@ Anti-alias Suavizar bordas Anti-alias Rasters Suavizar Rasters Anti-aliased Artwork Arte Suavizada + Anti-aliasing Method Método de Suavização +antiAlias Sharp Nítido + Crisp Definido + Strong Forte Anti-aliasing: Suavização: Appearance Aparência Appearance Attribute Atributo de Aparência @@ -338,6 +342,13 @@ Apply Bullets Aplicar Marcadores Numbered List Lista Numerada Apply Numbers Aplicar Numeração + Bullets and Numbering… Marcadores e Numeração… + Numbers Números + Level: Nível: + Bullet Character: Caractere do Marcador: + Number Style: Estilo de Número: + Left Indent: Recuo Esquerdo: + First Line Indent: Recuo da Primeira Linha: Convert to Text Converter em Texto Filled Dot Ponto Preenchido Dash Travessão @@ -500,6 +511,11 @@ Color Swatches Amostras de Cor Color Table Tabela de Cores Color Themes Temas de Cores + Color Wheel Roda de Cores + Hue ring Anel de Tom + Saturation and brightness Saturação e Brilho + Color field Campo de Cor + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Tipo de Cor: Color blindness – Deuteranopia-type Daltonismo – tipo deuteranopia Color blindness – Protanopia-type Daltonismo – tipo protanopia @@ -974,6 +990,14 @@ Export As PNG… Exportar Como PNG… Export As SVG… Exportar Como SVG… Export Assets Exportar Ativos + Export 3D Object Exportar Objeto 3D + 3D Export Format Formato de Exportação 3D + Export 3D Assets Exportar Ativos 3D +@msg {what} can't be exported in 3D: {e} não é possível exportar {what} em 3D: {e} +@msg asset {id} isn't a 3D asset o ativo {id} não é um ativo 3D +@msg asset {id} is a 3D asset: assets.export3D writes it o ativo {id} é um ativo 3D: assets.export3D o grava +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) ainda não há ativos 3D: colete primeiro um objeto com um efeito 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) selecione um objeto com um efeito 3D (ou informe seus ids) Export As… Exportar Como… Export CSS Exportar CSS Export CSS… Exportar CSS… @@ -1010,6 +1034,17 @@ Export: Exportar: Export… Exportar… Extrude & Bevel… Extrusão e Bisel… + 3D (Classic) 3D (Clássico) + Extrude & Bevel (Classic)… Extrusão e Bisel (Clássico)… + Surface Superfície + Wireframe Aramado + No Shading Sem Sombreamento + Plastic Plástico + Highlight Intensity Intensidade do Realce + Highlight Size Tamanho do Realce + Blend Steps Etapas de Mesclagem + Shading Color Cor do Sombreamento + Draw Hidden Faces Desenhar Faces Ocultas Eyedropper Conta-gotas Eyedropper Applies: O Conta-gotas Aplica: Eyedropper Options Opções do Conta-gotas @@ -1045,6 +1080,7 @@ Fill & Stroke Preenchimento e Traçado Fill Color Cor de Preenchimento Fill New Type Objects With Placeholder Text Preencher Novos Objetos de Texto com Texto de Placeholder + Start Lists From Typed Markers Iniciar Listas a Partir de Marcadores Digitados Fill Rule Regra de Preenchimento Fill and Stroke Preenchimento e Traçado Fill new strokes Preencher novos traçados @@ -2305,6 +2341,7 @@ digits Malayalam Malaiala Reset Transformation Redefinir Transformação Replace With Font Substituir Com Fonte Replace With Font From Substituir Com Fonte De + Find In Localizar em System Sistema Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Substitua por outro arquivo, ou ignore-o: suas imagens continuam mostrando a pré-visualização salva com o documento. Replaces the saved preset of that name. Substitui o predefinido salvo com esse nome. @@ -2563,6 +2600,11 @@ digits Malayalam Malaiala Selected Swatches Amostras Selecionadas Selected Swatches Only Somente Amostras Selecionadas Selection Seleção +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Seleção retangular + Touching Objetos tocados + Fully Enclosed Totalmente contidos + Drag Direction Direção do arrasto Selection & Anchor Display Exibição de Seleção e Âncora Selection Only Somente Seleção Selection Tool Ferramenta Seleção @@ -2681,6 +2723,7 @@ digits Malayalam Malaiala Simulate Colored Paper Simular Papel Colorido Simulate Paper Simular Papel Double Column Coluna Dupla + Keep Tools Beside the Canvas Ferramentas ao Lado da Tela Single Left Quotation Mark Aspa Esquerda Única Single Quotes: Aspas Simples: Single Right Quotation Mark Aspa Direita Única @@ -2878,6 +2921,7 @@ digits Malayalam Malaiala Tile Range: Intervalo de Blocos: Tile Type: Tipo de Bloco: Tiles are numbered across, then down; leave the range empty for every tile. Os blocos são numerados horizontalmente, depois para baixo; deixe o intervalo vazio para cada bloco. + Copies per Sheet (as many as fit, in a grid) Cópias por Folha (quantas couberem, em grade) Title Case Tipo de Título Toggle Fill/Stroke Focus Alternar Foco de Preenchimento/Traçado Toggle Selection Alternar Seleção @@ -3115,6 +3159,7 @@ digits Malayalam Malaiala Zoom Tool Ferramenta Zoom Zoom to Selection Ampliar para a Seleção Zoom with Mouse Wheel Zoom com a Roda do Mouse + Scroll Sideways with Cmd/Ctrl + Wheel Rolar na Horizontal com Cmd/Ctrl + Roda Scrub Numeric Fields by Dragging Ajustar Campos Numéricos Arrastando [Default] is protected: New… starts an editable copy. [Padrão] é protegido: Novo… inicia uma cópia editável. [Unnamed] [Sem Nome] @@ -3264,6 +3309,9 @@ imageTrace Outline Contorno Stained Glass… Vitral… Texturizer… Texturizador… System Title Bar Barra de título do sistema + VectorCraft Title Bar on Every Desktop Barra de título do VectorCraft em todas as áreas de trabalho + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Enquanto estiver definida, a variável de ambiente VECTORCRAFT_TITLEBAR escolhe a barra de título. + System Title Bar takes precedence over this option while it is on. Enquanto estiver ativada, Barra de título do sistema tem prioridade sobre esta opção. Exit Sair Distort Distorcer Diffuse Glow… Brilho difuso… @@ -3343,6 +3391,7 @@ font-technology Variable Variáveis Favorites Favoritos Fill (X), double-click for the Color Picker Preenchimento (X), clique duas vezes para o seletor de cores Find Objects Localizar objetos + Compare with Image Comparar com imagem Flare Tool Options Opções da ferramenta Reflexo Halo Halo Rays Raios @@ -3392,6 +3441,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 @@ -3557,7 +3610,7 @@ axis Horizontal Horizontal axis Vertical Vertical axis Both Ambos Drag to float the toolbar Arraste para flutuar a barra de ferramentas - Drag to the window's left edge to dock Arraste até a borda esquerda da janela para encaixar + Drag to the window's left or right edge to dock Arraste até a borda esquerda ou direita da janela para encaixar @msg no library `{id}` nenhuma biblioteca `{id}` @msg select some type first selecione algum texto primeiro @msg kerning must be a number, "auto", or "optical" O kerning deve ser um número, “auto” ou “optical” @@ -3662,6 +3715,7 @@ axis Both Ambos @msg Split Into Grid: select paths large enough for the grid Dividir em grade: selecione traçados grandes o bastante para a grade @msg Targa files are RGB: colorModel rgb arquivos Targa são RGB: colorModel rgb @msg Text Wrap: select a wrap object Quebra de texto: selecione um objeto de quebra +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` deve ser "none", "sharp", "crisp" ou "strong" @msg `aspect` must be a number (%) `aspect` deve ser um número (%) @msg `at` must be [x, y] `at` deve ser [x, y] @msg `color` must be a colour `color` deve ser uma cor @@ -3778,7 +3832,7 @@ axis Both Ambos @msg bad shortcut atalho inválido @msg bad stream length comprimento de fluxo inválido @msg bad trailer trailer inválido -@msg balance must be between 0 and 100 o equilíbrio deve estar entre 0 e 100 +@msg balance must be between 0 and 100 balance deve estar entre 0 e 100 @msg batch step {_1} (`{id}`) failed: {e} a etapa {_1} do lote (`{id}`) falhou: {e} @msg brightness must be dark|mediumDark|mediumLight|light brightness deve ser dark|mediumDark|mediumLight|light @msg can't encode the document: {0} não foi possível codificar o documento: {0} @@ -3805,7 +3859,7 @@ axis Both Ambos @msg compound paths can only contain paths traçados compostos só podem conter traçados @msg compressed (RLE) bitmaps can't be read yet bitmaps compactados (RLE) ainda não podem ser lidos @msg damaged danificado -@msg decimals must be {_1}–{_2} (got {_3}) as casas decimais devem estar entre {_1} e {_2} (recebido {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals deve estar entre {_1} e {_2} (recebido {_3}) @msg divide into 1 to {MAX_PIECES} slices divida em 1 a {MAX_PIECES} fatias @msg effect {k} is `{_1}`, not `{id}` o efeito {k} é `{_1}`, não `{id}` @msg empty brush stroke traço de pincel vazio @@ -3929,6 +3983,8 @@ axis Both Ambos @msg no such action set conjunto de ações inexistente @msg the action sets changed; nothing was deleted os conjuntos de ações mudaram; nada foi excluído @msg no action set to save nenhum conjunto de ações para salvar +@msg the action set file can't be read in full o arquivo de conjunto de ações não pode ser lido por inteiro +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” carregado: {n} etapa foi omitida|“{name}” carregado: {n} etapas foram omitidas @msg not an action set file não é um arquivo de conjunto de ações @msg the action set file is larger than 8 MiB o arquivo de conjunto de ações tem mais de 8 MiB @msg the action set file holds more than 1,000 actions o arquivo de conjunto de ações contém mais de 1.000 ações @@ -4001,7 +4057,7 @@ axis Both Ambos @msg replace: the object to replace was changed or deleted replace: o objeto a ser substituído foi alterado ou excluído @msg pattern missing padrão ausente @msg patterns nested too deeply are filled with mid-grey padrões aninhados com profundidade excessiva são preenchidos com cinza médio -@msg percent must be above 0 and at most 6400, not {p} a porcentagem deve ser maior que 0 e no máximo 6400, não {p} +@msg percent must be above 0 and at most 6400, not {p} percent deve ser maior que 0 e no máximo 6400, não {p} @msg pick a colour, gradient or pattern to save as a swatch escolha uma cor, gradiente ou padrão para salvar como amostra @msg plane must be left, right or ground plane deve ser left, right ou ground @msg plug-in ABI: {0} ABI do plug-in: {0} @@ -4022,8 +4078,8 @@ axis Both Ambos @msg result would exceed the canvas (coordinates out of range) o resultado excederia a tela (coordenadas fora do intervalo) @msg objects can't be nested more than 32 deep os objetos não podem ser aninhados em mais de 32 níveis @msg the SVG's elements are nested more than {0} deep os elementos do SVG estão aninhados em mais de {0} níveis -@msg scale must be a number from {_1} to {_2}, not {_3} a escala deve ser um número de {_1} a {_2}, não {_3} -@msg scale must be a number from {_1} to {_2}, not {s} a escala deve ser um número de {_1} a {_2}, não {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale deve ser um número de {_1} a {_2}, não {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale deve ser um número de {_1} a {_2}, não {s} @msg select (or target) objects whose appearance isn't basic selecione (ou direcione) objetos cuja aparência não seja básica @msg select a Live Paint group selecione um grupo de Pintura dinâmica @msg select a blend selecione uma mesclagem @@ -4051,6 +4107,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 @@ -4287,11 +4350,15 @@ axis Both Ambos @msg its objects are nested too deeply seus objetos estão aninhados com profundidade excessiva @msg its section ending with `%{end}` doesn't end sua seção que termina com `%{end}` não termina @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) os pincéis que o VectorCraft ainda não consegue ler ficaram fora do painel Pincéis ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objetos com vários preenchimentos ou traços, efeitos ou pincéis entraram com a aparência desenhada ({_1}): sua aparência ainda não é editável +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objetos com vários preenchimentos ou traços, traços de largura variável, efeitos ou pincéis entraram com a aparência desenhada ({_1}): sua aparência ainda não é editável +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding o nome `{name}` não está em UTF-8 e foi lido como {label}: se parecer errado, abra o arquivo com outro legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding o nome `{name}` não está em UTF-8, e partes dele também não são {label} válido: abra o arquivo com outro legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) mais nomes que não estão em UTF-8 foram lidos como {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it o gradiente “{name}” não está definido no arquivo: uma cor sólida o substitui @msg an image VectorCraft can't read was left out uma imagem que o VectorCraft não consegue ler foi omitida @msg graphs come in as plain art, without their data or settings os gráficos são importados como arte comum, sem seus dados nem configurações @msg an image whose data is cut short was left out uma imagem com dados incompletos foi omitida +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers uma imagem incorporada de {_1} × {_2} pixels foi reduzida para {_3} × {_4} pixels para manter as camadas @msg its editing data doesn't say where its art is seus dados de edição não indicam onde está a arte @msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) este arquivo foi salvo sem compatibilidade com PDF e seus dados de edição não podem ser lidos ({why}) @msg the PDF part couldn't be read ({_1}): the file opened from its editing data não foi possível ler a parte PDF ({_1}): o arquivo foi aberto a partir dos seus dados de edição @@ -4320,6 +4387,7 @@ axis Both Ambos @msg object ids are non-negative integers, not {v} os ids de objeto são inteiros não negativos, não {v} @msg {key} must be an array of non-negative integer object ids {key} deve ser uma matriz de ids de objeto que sejam inteiros não negativos @msg invalid object id {value}: expected a non-negative integer id de objeto inválido {value}: era esperado um inteiro não negativo +@msg `{key}` entry {index} is {value}, not a non-negative integer object id a entrada {index} de `{key}` é {value}, não um id de objeto (inteiro não negativo) @msg no such object id {value} não existe objeto com o id {value} @msg id {value} is a layer, not an object o id {value} é uma camada, não um objeto @msg id {value} is a layer, not an object; move layers with layer.move o id {value} é uma camada, não um objeto; mova camadas com layer.move @@ -4346,12 +4414,19 @@ axis Both Ambos @msg the Pixar file's image data is damaged os dados de imagem do arquivo Pixar estão danificados @msg the JPEG 2000 file can't be read não é possível ler o arquivo JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content o arquivo JPEG 2000 contém um espaço de cores ou conteúdo JPX sem suporte +@msg the HEIC or HEIF file can't be read não é possível ler o arquivo HEIC ou HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read o arquivo HEIC ou HEIF contém um tipo de imagem que não pode ser lido +@msg HEIC and HEIF images can't be opened in this build imagens HEIC e HEIF não podem ser abertas nesta versão @msg the PSD image is too large to read ({_1} × {_2} pixels) a imagem PSD é grande demais para ser lida ({_1} × {_2} pixels) @msg zipped 32-bit PSD image data with prediction can't be read dados de imagem PSD de 32 bits compactados em zip com previsão não podem ser lidos @msg {depth}-bit PSD files in this colour mode can't be read arquivos PSD de {depth} bits neste modo de cor não podem ser lidos @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 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 Define a variable first Defina uma variável primeiro Text Texto @@ -4362,7 +4437,7 @@ axis Both Ambos @msg before must name a panel before deve nomear um painel @msg docking action contains an unknown panel or an excessive split path a ação de encaixe contém um painel desconhecido ou um caminho de divisão excessivo @msg group must be true or false group deve ser true ou false -@msg panel is not in a dock group o painel não está em um grupo do encaixe +@msg panel is not in a dock group panel não está em um grupo do encaixe @msg only a dock group above or below others collapses, not one beside another só um grupo do encaixe acima ou abaixo de outros é recolhido, não um ao lado @msg icon must be true or false icon deve ser true ou false @msg icon only docks a panel in the icon column, with no anchor icon apenas encaixa um painel na coluna de ícones, sem anchor @@ -4683,6 +4758,7 @@ axis Both Ambos Report copied Relatório copiado Line {line} Linha {line} Line {line}: {name}: {status} Linha {line}: {name}: {status} + Read Action Set File Ler Arquivo de Conjunto de Ações Read Shortcut Set File Ler Arquivo de Conjunto de Atalhos Import Matches Importar Correspondências Remember Import Matches Lembrar Correspondências de Importação @@ -4885,6 +4961,15 @@ axis Both Ambos Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Configurações de Tate-chu-yoko… Tate-chu-yoko Settings Configurações de Tate-chu-yoko + Composite Fonts… Fontes compostas… + Composite Fonts Fontes compostas + Export Composite Fonts Exportar fontes compostas + Import Composite Fonts Importar fontes compostas + Delete Font Excluir fonte + Kanji Kanji + Kana Kana + Numerals Números + Scale at Center Dimensionar a partir do centro Up/Down: Para Cima/Para Baixo: Left/Right: Esquerda/Direita: Temperature Temperatura @@ -5447,6 +5532,11 @@ mark-style Japanese Japonesas @msg The shape is too complex for a 3D effect A forma é complexa demais para um efeito 3D @msg The 3D surface is too complex; simplify the shape A superfície 3D é complexa demais; simplifique a forma @msg an image of {w} × {h} pixels is too large to draw uma imagem de {w} × {h} pixels é grande demais para ser desenhada +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: não é uma imagem PNG, JPEG, WebP, TIFF ou BMP +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ a imagem tem {iw} × {ih} pixels e a prancheta nessa largura tem {rw} × {rh} pixels: as proporções são diferentes +@msg `threshold` must be a number from 0 to 1 `threshold` deve ser um número de 0 a 1 +@msg background {v}: a color such as "#808080" plano de fundo {v}: uma cor como "#808080" +@msg comparing with an image file isn't available in the browser a comparação com um arquivo de imagem não está disponível no navegador @msg an image of {w} × {h} pixels is shown at reduced size uma imagem de {w} × {h} pixels é exibida em tamanho reduzido Fill and Stroke Controls Controles de Preenchimento e Traçado Coloring Controls Controles de Coloração @@ -5620,11 +5710,19 @@ mark-style Japanese Japonesas @msg too many library panels were opened in this session foram abertos painéis de biblioteca demais nesta sessão @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. A imagem tem {_1} × {_2} pixels, mais de {LARGE_TRACE_SIDE} em um lado: ela é traçada, mas mais devagar. Uma predefinição de menor fidelidade ou uma cópia menor da imagem traça mais rápido. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). A imagem foi traçada em {_1} × {_2} pixels, reduzida de {_3} × {_4} (no máximo {_5} megapixels). +@msg byColor must be true or false byColor deve ser true ou false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor não pode dividir os grupos de Agrupamento Automático: trace sem Agrupamento Automático +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor não pode reagrupar um traçado com um efeito Pathfinder ativo: expanda sem byColor Snapping Ajuste Snap to Tangent Ajustar à Tangente Snap to Perpendicular Ajustar à Perpendicular @msg unknown snap option `{key}` (use {options}) opção de ajuste desconhecida `{key}` (use {options}) Gap Length: Comprimento da Lacuna: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. O modo Logotipo Plano não usa keepColors: ele traça as cores indicadas em logoColors ou as cores que encontra. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Nada foi traçado: nenhuma das cores que o traçado encontrou corresponde a keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Nada foi traçado: Ignorar Cor deixa de fora todas as áreas das cores listadas em keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Nada foi traçado: as cores que correspondem a keepColors não geram nenhum caminho. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: nenhuma das cores que o traçado encontrou corresponde a {c}, então nada dela é traçado. @msg `colorGroup` needs `library: "document"` `colorGroup` requer `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` requer `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` requer `mode: "color"` @@ -5736,11 +5834,14 @@ axis Y Axis Eixo Y @msg Running “{name}”… Executando “{name}”… @msg a live effect runs while the document is drawn, so it can't be `heavy` um efeito dinâmico é executado enquanto o documento é desenhado, então não pode ser `heavy` @msg an exporter always gets an export's budget: leave `budget` out um plug-in de exportação sempre recebe o orçamento de uma exportação: omita `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” pede um orçamento de processamento grande (cada execução até {minutes} minutos e {times} vezes as instruções habituais): instale-o com `allowHeavy: true` depois que o usuário concordar +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” pede um orçamento de processamento grande (cada execução até {minutes} minutos e {times} vezes as instruções habituais): uma pessoa precisa aprová-lo no aplicativo de desktop, ou passe `allowHeavy: true` em uma sessão iniciada com --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Instalar “{name}”, que pede estas permissões? ID: {id} ID: {id} Version: {version} Versão: {version} + Install “{name}” in place of the plug-in with the same ID? Instalar “{name}” no lugar do plug-in com o mesmo ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Este arquivo não é o plug-in instalado antes com este ID. Ele recebe as configurações e o cache guardados para esse ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” é um módulo diferente do plug-in com ID {_1} e receberia as configurações e o cache guardados para ele: instale-o com `replace: true` depois que o usuário concordar File SHA-256: {hash} SHA-256 do arquivo: {hash} Read the objects it is given Ler os objetos que recebe Change, add and delete objects in the document Alterar, adicionar e excluir objetos no documento @@ -5748,21 +5849,43 @@ 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 Show its progress, see Cancel and write log lines Mostrar seu progresso, detectar o cancelamento e gravar linhas de log -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a permissão `progress` exige o nível de API de plug-in {PROGRESS_API}: informe `api` {PROGRESS_API} + Keep settings of its own Guardar configurações próprias + Keep a cache of its own, which you can clear Guardar um cache próprio, que você pode limpar + Clear Plug-in Cache Limpar cache do plug-in +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a permissão `{permission}` exige o nível de API de plug-in {PROGRESS_API}: informe `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} o módulo importa `env.{name}` com um tipo diferente do tipo do host: o do host é {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` o módulo importa `env.{name}`, que um plug-in só pode usar no nível de API de plug-in {PROGRESS_API} (`api` {PROGRESS_API}) e com a permissão `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network o módulo importa `{from}.{name}`, que o host não oferece: um plug-in só pode importar `env.vc_progress`, `env.vc_cancelled` e `env.vc_log` (nível de API de plug-in 2), e nenhum WASI, arquivo ou rede +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` o módulo importa `env.{name}`, que um plug-in só pode usar no nível de API de plug-in {PROGRESS_API} (`api` {PROGRESS_API}) e com a permissão `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network o módulo importa `{from}.{name}`, que o host não oferece: um plug-in só pode importar as funções `env.vc_*` listadas em docs/plugins.md (nível de API de plug-in 2), e nenhum WASI, arquivo ou rede @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` may only be true `paintOutput` só pode ser true +@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}` @msg at most {MAX_PERMISSIONS} `permissions` no máximo {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` a permissão `{shown}` não se aplica a um plug-in do tipo `{_1}` @msg permission `{shown}` is listed twice a permissão `{shown}` aparece duas vezes +@msg only a filter declares `inputs` somente um filtro declara `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` cita um tipo de objeto desconhecido `{shown}`: use type, image ou symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` lista `{shown}` duas vezes +@msg `inputs` needs `api` 2 or later `inputs` exige `api` 2 ou posterior +@msg `inputs` needs the permission `doc.read` `inputs` exige a permissão `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) orçamento de memória (o objeto {_1} precisa de {size} bytes de entrada, restam {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) orçamento de objetos (mais de {MAX_OBJECTS} objetos somente leitura) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a permissão `{shown}` está reservada para uma API de plug-in futura; este host não a concede @msg unknown permission `{shown}` permissão desconhecida `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pede as permissões {asked}: instale-o com `grant` listando-as depois que o usuário concordar +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” pede as permissões {asked}: uma pessoa precisa aprová-las no aplicativo de desktop, ou passe `grant` em uma sessão iniciada com --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` e `allowHeavy` só têm efeito em uma sessão iniciada com --allow-plugin-grants: aprove o plug-in no aplicativo de desktop +@msg Only the person at the keyboard can approve installing a plug-in Somente a pessoa ao teclado pode aprovar a instalação de um plug-in @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” não recebeu `{p}`, de que sua saída precisa: nada foi alterado +@msg a heavy filter runs in the background, so it can't declare `commands` um filtro pesado é executado em segundo plano, então não pode declarar `commands` +@msg at most {MAX_COMMANDS} `commands` no máximo {MAX_COMMANDS} `commands` +@msg command {name} is declared twice o comando {name} está declarado duas vezes +@msg command {name}: `label` must be 1-64 printable characters comando {name}: `label` deve ter 1-64 caracteres imprimíveis +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter comando {name}: use um id de 1-{MAX_COMMAND_ID} caracteres de a-z 0-9 _ que comece com uma letra +@msg command {name}: {e} comando {name}: {e} +@msg only a filter or a live effect declares `commands` só um filtro ou um efeito dinâmico declara `commands` +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed o módulo não tem o SHA-256 indicado em `hash`: nada foi instalado # Actions panel: action sets (#3712). New Set… Novo Conjunto… @@ -6181,6 +6304,8 @@ breakText Glyphs Glifos File + Data Set Name Arquivo + nome do conjunto de dados Data Set Name Nome do conjunto de dados @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` deve ser `fileNumber`, `fileDataSetName` ou `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Cada conjunto de dados é salvo sobre o mesmo arquivo, que mantém apenas o último conjunto de dados. +@msg save the document with Save As before Save and Close salve o documento com Salvar Como antes de Salvar e fechar @msg no free data set file name in destination folder Não há nome de arquivo livre para o conjunto de dados na pasta de destino # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6302,9 +6427,20 @@ breakText Glyphs Glifos Reset Symbol Instance Redefinir instância de símbolo @msg type must be static or dynamic, not {v} type deve ser static ou dynamic, não {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` é um símbolo estático: defina primeiro o tipo de símbolo como Dinâmico +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change De um caminho, caminho composto ou texto selecionado em uma instância de símbolo só é possível alterar a cor sólida do preenchimento e do traçado @msg path must be a list of child indices path deve ser uma lista de índices de filhos @msg no object at path {shown} in the symbol's art nenhum objeto no caminho {shown} na arte do símbolo @msg give fill, stroke, opacity and/or blend informe fill, stroke, opacity e/ou blend +@msg {key} cannot be set on a guide {key} não pode ser definido em uma guia +@msg {key} cannot be set on a blend {key} não pode ser definido em uma mesclagem +@msg {key} cannot be set on an envelope {key} não pode ser definido em um envelope +@msg {key} cannot be set on a gradient mesh {key} não pode ser definido em uma malha de gradiente +@msg {key} cannot be set on a Repeat {key} não pode ser definido em uma repetição +@msg {key} cannot be set on a placed document {key} não pode ser definido em um documento colocado +@msg {key} cannot be set on an Intertwine group {key} não pode ser definido em um grupo entrelaçado +@msg stroke cannot be set on a symbol instance stroke não pode ser definido em uma instância de símbolo +@msg fill on a symbol instance must be a solid color or none o fill de uma instância de símbolo deve ser uma cor sólida ou none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} não pode ser definido em uma imagem sem o efeito Converter em Forma ou Contornar Objeto @msg pass newName, registration, exportType, nineSlice and/or type informe newName, registration, exportType, nineSlice e/ou type # Strings the sibling catalogs translate that pt-BR did not yet cover (translated 2026-10-31). on the canvas to create one na tela para criar um @@ -6489,6 +6625,7 @@ swatch Yellow Green Verde-amarelado Create Swatch Legend Criar Legenda de Amostras Create Swatch Legend… Criar Legenda de Amostras… HEX HEX + Help Bar Barra de ajuda # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Ferramentas de texto Text Tweaker… Ajustador de texto… @@ -6554,3 +6691,44 @@ 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 plug-in of kind `{_2}`, not an importer `{_1}` é um plug-in do tipo `{_2}`, não um plug-in de importação +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` é um plug-in do tipo `{_2}`, não um plug-in de exportaçã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` + +# Safe Mode + Safe Mode… Modo de Segurança… + Safe Mode Modo de Segurança + Help › Safe Mode… Ajuda › Modo de Segurança… + Enable on Re-launch Ativar na Próxima Inicialização + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. A última inicialização do VectorCraft parou enquanto o plug-in {name} era carregado. Ele pode não ser a causa. + Disable and Continue Desativar e Continuar + Start Normally Iniciar Normalmente + Start in Safe Mode Iniciar no Modo de Segurança + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. O VectorCraft está no Modo de Segurança: nenhum plug-in é carregado ou executado até que ele seja iniciado normalmente. + Disabled plug-ins Plug-ins desativados + No plug-ins are disabled. Nenhum plug-in está desativado. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally O Modo de Segurança está ativado: os plug-ins não são carregados nem executados até que o VectorCraft seja iniciado normalmente +@msg give `id` or `file` (see plugin.safeMode) informe `id` ou `file` (veja plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) esse plug-in não está desativado (veja plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch Não foi possível salvar os plug-ins desativados para a próxima inicialização +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Expandir aparência: um efeito de plug-in na seleção não pode ser expandido no Modo de Segurança + +# Align to Pixel Grid + Align to Pixel Grid Alinhar à Grade de Pixels +@msg only paths and compound paths can be aligned to the pixel grid somente caminhos e caminhos compostos podem ser alinhados à grade de pixels +@msg side is left or right, not `{s}` side é left ou right, não `{s}` diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3732558f7..5ada469cc 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -196,6 +196,10 @@ Anti-Alias Сглаживание Anti-alias Rasters Сглаживать растровые изображения Anti-aliased Artwork Сглаженный рисунок + Anti-aliasing Method Метод сглаживания +antiAlias Sharp Резкое + Crisp Чёткое + Strong Сильное Anti-aliasing: Сглаживание: Any Kind Любой тип Font Technology Технология шрифта @@ -365,6 +369,13 @@ font-technology Variable Вариативные Apply Bullets Применить маркеры Numbered List Нумерованный список Apply Numbers Применить нумерацию + Bullets and Numbering… Маркеры и нумерация… + Numbers Номера + Level: Уровень: + Bullet Character: Символ маркера: + Number Style: Стиль нумерации: + Left Indent: Отступ слева: + First Line Indent: Отступ первой строки: Convert to Text Преобразовать в текст Filled Dot Закрашенная точка Dash Тире @@ -553,6 +564,11 @@ font-technology Variable Вариативные Color Table ({n} colors) Цветовая таблица ({n} цветов) Color Theme Цветовая тема Color Themes Цветовые темы + Color Wheel Цветовой круг + Hue ring Кольцо оттенков + Saturation and brightness Насыщенность и яркость + Color field Цветовое поле + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Тип цвета: Color: Цвет: Colorize Тонировать @@ -1084,6 +1100,14 @@ font-technology Variable Вариативные Export As SVG… Экспортировать как SVG… Export Asset Экспортировать ресурс Export Assets Экспортировать ресурсы + Export 3D Object Экспортировать 3D-объект + 3D Export Format Формат 3D-экспорта + Export 3D Assets Экспортировать 3D-ресурсы +@msg {what} can't be exported in 3D: {e} {what} невозможно экспортировать в 3D: {e} +@msg asset {id} isn't a 3D asset ресурс {id} не является 3D-ресурсом +@msg asset {id} is a 3D asset: assets.export3D writes it ресурс {id} является 3D-ресурсом: его записывает assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) 3D-ресурсов пока нет: сначала соберите объект с 3D-эффектом (assets.add3D) +@msg select an object with a 3D effect (or give its ids) выберите объект с 3D-эффектом (или укажите его идентификаторы) Export As… Экспортировать как… Export CSS Экспортировать CSS Export CSS… Экспортировать CSS… @@ -1115,6 +1139,17 @@ font-technology Variable Вариативные Exporting {} Экспорт {} Export… Экспорт… Extrude & Bevel… Выдавливание и фаска… + 3D (Classic) 3D (классические) + Extrude & Bevel (Classic)… Выдавливание и фаска (классическое)… + Surface Поверхность + Wireframe Каркас + No Shading Без затенения + Plastic Пластик + Highlight Intensity Интенсивность блика + Highlight Size Размер блика + Blend Steps Шаги перехода + Shading Color Цвет затенения + Draw Hidden Faces Рисовать скрытые грани Eyedropper Пипетка Sample Color from Screen Взять цвет с экрана This system has no screen color sampler В этой системе нет средства выбора цвета с экрана @@ -1172,6 +1207,7 @@ font-technology Variable Вариативные Stroke When Editable Редактируемая обводка Fill Color Цвет заливки Fill New Type Objects With Placeholder Text Заполнять новые текстовые объекты текстом-заполнителем + Start Lists From Typed Markers Начинать списки с введённых маркеров Fill Rule Правило заливки Fill with Placeholder Text Заполнить текстом-заполнителем Fill: Заливка: @@ -1182,6 +1218,7 @@ font-technology Variable Вариативные Find Font… Найти шрифт… Find Next Найти далее Find Objects Найти объекты + Compare with Image Сравнить с изображением First Page Первая страница First-line Left Indent Отступ слева в первой строке Fisheye… Рыбий глаз… @@ -1741,6 +1778,7 @@ file-filter Templates Шаблоны Line {line} Строка {line} Line {line}: {name}: {status} Строка {line}: {name}: {status} Imported. Press OK to keep the imported set. Импортировано. Нажмите «ОК», чтобы сохранить импортированный набор. + Read Action Set File Прочитать файл набора действий Read Shortcut Set File Прочитать файл набора сочетаний клавиш Import Matches Сопоставления импорта Remember Import Matches Запомнить сопоставления импорта @@ -1989,6 +2027,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 монитора @@ -2791,6 +2833,7 @@ digits Malayalam Малаялам Reset Transformation Сбросить трансформацию Replace With Font Заменить шрифтом Replace With Font From Заменить шрифтом из + Find In Искать в System Система Replaces the saved preset of that name. Заменяет сохранённый набор с таким именем. Replace… Заменить… @@ -3091,6 +3134,11 @@ digits Malayalam Малаялам Selected Swatches Выбранные образцы Selected Swatches Only Только выбранные образцы Selection Выделение +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Выделение рамкой + Touching Пересекаемые + Fully Enclosed Полностью внутри + Drag Direction Направление перетаскивания Selection & Anchor Display Отображение выделения и опорных точек Selection Only Только выделение Selection Tool Инструмент «Выделение» @@ -3228,6 +3276,7 @@ digits Malayalam Малаялам Simulate Colored Paper Имитировать цветную бумагу Simulate Paper Имитировать бумагу Double Column Двойной столбец + Keep Tools Beside the Canvas Инструменты рядом с холстом Single Left Quotation Mark Одинарная открывающая кавычка Single Quotes: Одинарные кавычки: Single Right Quotation Mark Одинарная закрывающая кавычка @@ -3476,6 +3525,15 @@ digits Malayalam Малаялам Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Параметры Tate-chu-yoko… Tate-chu-yoko Settings Параметры Tate-chu-yoko + Composite Fonts… Составные шрифты… + Composite Fonts Составные шрифты + Export Composite Fonts Экспорт составных шрифтов + Import Composite Fonts Импорт составных шрифтов + Delete Font Удалить шрифт + Kanji Кандзи + Kana Кана + Numerals Цифры + Scale at Center Масштабировать от центра Up/Down: Вверх/вниз: Left/Right: Влево/вправо: Temperature Температура @@ -3529,6 +3587,7 @@ digits Malayalam Малаялам Tile Type: Тип плитки: tile {number} плитка {number} Tiles are numbered across, then down; leave the range empty for every tile. Плитки нумеруются слева направо, затем вниз; оставьте диапазон пустым для всех плиток. + Copies per Sheet (as many as fit, in a grid) Копий на лист (сколько поместится, сеткой) Tint Оттенок Tip extends past the end point Кончик выходит за конечную точку Tip on the end point Кончик на конечной точке @@ -3824,6 +3883,7 @@ digits Malayalam Малаялам Zoom to Selection Масштаб по выделению Zoom Tool Инструмент «Масштаб» Zoom with Mouse Wheel Масштабировать колесом мыши + Scroll Sideways with Cmd/Ctrl + Wheel Прокручивать по горизонтали колесом с Cmd/Ctrl Scrub Numeric Fields by Dragging Изменять числовые поля перетаскиванием @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? Файлов: {n} в {folder} {note}. Показать папку пакета?|Файлов: {n} в {folder} {note}. Показать папку пакета?|Файлов: {n} в {folder} {note}. Показать папку пакета? @plural {n} linked file not found|{n} linked files not found Не найдено связанных файлов: {n}|Не найдено связанных файлов: {n}|Не найдено связанных файлов: {n} @@ -3998,7 +4058,7 @@ axis Both Оба Dock to Bottom Прикрепить снизу Dock Control Bar Прикрепить панель управления Drag to float the toolbar Перетащите, чтобы сделать панель инструментов плавающей - Drag to the window's left edge to dock Перетащите к левому краю окна для прикрепления + Drag to the window's left or right edge to dock Перетащите к левому или правому краю окна для прикрепления Float Tool Group Сделать группу инструментов плавающей # Status and error messages (`@msg`), translated where the status bar shows them; the message itself @@ -4007,7 +4067,7 @@ axis Both Оба # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` нет библиотеки `{id}` @msg select some type first сначала выделите текст -@msg kerning must be a number, "auto", or "optical" Кернинг должен быть числом, «auto» или «optical» +@msg kerning must be a number, "auto", or "optical" kerning должен быть числом, «auto» или «optical» @msg the selection has no gradient fill у выделения нет градиентной заливки @msg the selection has no gradient stroke у выделения нет градиентной обводки @msg the selection has no pattern fill у выделения нет узорной заливки @@ -4111,6 +4171,7 @@ axis Both Оба @msg Targa files are RGB: colorModel rgb файлы Targa являются RGB: colorModel rgb @msg Text Wrap: select a wrap object Обтекание текстом: выделите объект обтекания @msg This shortcut file is in a form VectorCraft can't read. Этот файл сочетаний клавиш записан в формате, который VectorCraft не может прочитать. +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` должно быть "none", "sharp", "crisp" или "strong" @msg `aspect` must be a number (%) `aspect` должно быть числом (%) @msg `at` must be [x, y] `at` должно быть [x, y] @msg `color` must be a colour `color` должно быть цветом @@ -4234,9 +4295,9 @@ axis Both Оба @msg bad shortcut недопустимое сочетание клавиш @msg bad stream length недопустимая длина потока @msg bad trailer недопустимый трейлер -@msg balance must be between 0 and 100 баланс должен быть от 0 до 100 +@msg balance must be between 0 and 100 balance должен быть от 0 до 100 @msg batch step {_1} (`{id}`) failed: {e} шаг пакета {_1} (`{id}`) не выполнен: {e} -@msg brightness must be dark|mediumDark|mediumLight|light яркость должна быть dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light brightness должен быть dark|mediumDark|mediumLight|light @msg can't encode the document: {0} не удалось закодировать документ: {0} @msg can't parse shortcut `{c}` не удалось разобрать сочетание `{c}` @msg can't parse shortcut `{s}` for `{k}` не удалось разобрать сочетание `{s}` для `{k}` @@ -4261,7 +4322,7 @@ axis Both Оба @msg compound paths can only contain paths составные контуры могут содержать только контуры @msg compressed (RLE) bitmaps can't be read yet сжатые (RLE) растровые изображения пока не читаются @msg damaged повреждено -@msg decimals must be {_1}–{_2} (got {_3}) число десятичных знаков должно быть {_1}–{_2} (получено {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals должен быть {_1}–{_2} (получено {_3}) @msg divide into 1 to {MAX_PIECES} slices разделите на 1–{MAX_PIECES} фрагментов @msg effect {k} is `{_1}`, not `{id}` эффект {k} — это `{_1}`, а не `{id}` @msg empty brush stroke пустой мазок кисти @@ -4386,6 +4447,8 @@ axis Both Оба @msg no such action set такого набора действий нет @msg the action sets changed; nothing was deleted наборы действий изменились; ничего не удалено @msg no action set to save нет набора действий для сохранения +@msg the action set file can't be read in full файл набора действий невозможно прочитать целиком +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Загружено «{name}»: пропущен {n} шаг|Загружено «{name}»: пропущено {n} шага|Загружено «{name}»: пропущено {n} шагов @msg not an action set file это не файл набора действий @msg the action set file is larger than 8 MiB файл набора действий больше 8 MiB @msg the action set file holds more than 1,000 actions файл набора действий содержит более 1000 действий @@ -4460,9 +4523,9 @@ axis Both Оба @msg replace: the object to replace was changed or deleted replace: заменяемый объект был изменён или удалён @msg pattern missing узор отсутствует @msg patterns nested too deeply are filled with mid-grey слишком глубоко вложенные узоры заполняются серым -@msg percent must be above 0 and at most 6400, not {p} процент должен быть больше 0 и не более 6400, а не {p} +@msg percent must be above 0 and at most 6400, not {p} percent должен быть больше 0 и не более 6400, а не {p} @msg pick a colour, gradient or pattern to save as a swatch выберите цвет, градиент или узор для сохранения в качестве образца -@msg plane must be left, right or ground плоскость должна быть left, right или ground +@msg plane must be left, right or ground plane должен быть left, right или ground @msg plug-in ABI: {0} ABI модуля: {0} @msg plug-in exceeded its {0} модуль превысил свой {0} @msg plug-in failed: {0} модуль завершился с ошибкой: {0} @@ -4481,8 +4544,8 @@ axis Both Оба @msg result would exceed the canvas (coordinates out of range) результат превысил бы холст (координаты вне диапазона) @msg objects can't be nested more than 32 deep объекты нельзя вкладывать глубже 32 уровней @msg the SVG's elements are nested more than {0} deep элементы SVG вложены глубже {0} уровней -@msg scale must be a number from {_1} to {_2}, not {_3} масштаб должен быть числом от {_1} до {_2}, а не {_3} -@msg scale must be a number from {_1} to {_2}, not {s} масштаб должен быть числом от {_1} до {_2}, а не {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale должен быть числом от {_1} до {_2}, а не {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale должен быть числом от {_1} до {_2}, а не {s} @msg select (or target) objects whose appearance isn't basic выделите (или укажите как объект) объекты, оформление которых не является основным @msg select a Live Paint group выделите группу быстрой заливки @msg select a blend выделите переход @@ -4510,6 +4573,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 выделите объекты, а не слои @@ -4747,11 +4817,15 @@ axis Both Оба @msg its objects are nested too deeply объекты в них вложены слишком глубоко @msg its section ending with `%{end}` doesn't end раздел, заканчивающийся на `%{end}`, не заканчивается @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) кисти, которые VectorCraft пока не умеет читать, не добавлены в панель «Кисти» ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet объекты с несколькими заливками или обводками, эффектами или кистями импортированы в нарисованном виде ({_1}): их оформление пока нельзя редактировать +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet объекты с несколькими заливками или обводками, обводками переменной ширины, эффектами или кистями импортированы в нарисованном виде ({_1}): их оформление пока нельзя редактировать +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding имя `{name}` не в UTF-8 и прочитано как {label}: если оно выглядит неверно, откройте файл с другим legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding имя `{name}` не в UTF-8, и его части недопустимы и в {label}: откройте файл с другим legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) другие имена не в UTF-8 прочитаны как {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it градиент «{name}» не определён в файле: вместо него используется сплошной цвет @msg an image VectorCraft can't read was left out изображение, которое VectorCraft не может прочитать, пропущено @msg graphs come in as plain art, without their data or settings диаграммы импортируются как обычная графика, без их данных и настроек @msg an image whose data is cut short was left out изображение с обрезанными данными пропущено +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers встроенное изображение {_1} × {_2} пикселей уменьшено до {_3} × {_4} пикселей, чтобы сохранить слои @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel связанные изображения показываются как сохранённый в файле просмотр низкого разрешения, пока их файлы не найдены: восстановите связи на панели «Связи» @msg linked type areas past VectorCraft's limits were left out связанные текстовые области за пределами ограничений VectorCraft пропущены @msg type that changes writing direction inside a text object opens in the direction of its first character текст, меняющий направление письма внутри текстового объекта, открывается в направлении своего первого символа @@ -4815,6 +4889,7 @@ axis Both Оба @msg object ids are non-negative integers, not {v} идентификаторы объектов — неотрицательные целые числа, а не {v} @msg {key} must be an array of non-negative integer object ids {key} должно быть массивом идентификаторов объектов, которые являются неотрицательными целыми числами @msg invalid object id {value}: expected a non-negative integer недопустимый идентификатор объекта {value}: ожидается неотрицательное целое число +@msg `{key}` entry {index} is {value}, not a non-negative integer object id элемент {index} в `{key}` равен {value}, а не идентификатору объекта (неотрицательному целому числу) @msg no such object id {value} объект с идентификатором {value} не существует @msg id {value} is a layer, not an object идентификатор {value} относится к слою, а не к объекту @msg id {value} is a layer, not an object; move layers with layer.move идентификатор {value} относится к слою, а не к объекту; перемещайте слои с помощью layer.move @@ -4841,12 +4916,19 @@ axis Both Оба @msg the Pixar file's image data is damaged данные изображения в файле Pixar повреждены @msg the JPEG 2000 file can't be read не удается прочитать файл JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content файл JPEG 2000 содержит неподдерживаемое цветовое пространство или содержимое JPX +@msg the HEIC or HEIF file can't be read не удается прочитать файл HEIC или HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read файл HEIC или HEIF содержит изображение такого типа, которое не удается прочитать +@msg HEIC and HEIF images can't be opened in this build изображения HEIC и HEIF нельзя открыть в этой сборке @msg the PSD image is too large to read ({_1} × {_2} pixels) изображение PSD слишком велико для чтения ({_1} × {_2} пикс.) @msg zipped 32-bit PSD image data with prediction can't be read 32-битные данные изображения PSD со сжатием ZIP с предсказанием не читаются @msg {depth}-bit PSD files in this colour mode can't be read {depth}-битные файлы PSD в этом цветовом режиме не читаются @msg the PSD file is cut short файл PSD обрезан @msg the PSD file's header is damaged заголовок файла PSD повреждён @msg the PSD file's image data can't be read: {e} не удаётся прочитать данные изображения файла PSD: {e} +@msg 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 Видео De-Interlace… Устранение чересстрочности… De-Interlace Устранение чересстрочности @@ -4902,6 +4984,9 @@ axis Both Оба Bottom Right Снизу справа Edge Край System Title Bar Системная строка заголовка + VectorCraft Title Bar on Every Desktop Строка заголовка VectorCraft на любом рабочем столе + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Пока переменная среды VECTORCRAFT_TITLEBAR задана, строку заголовка выбирает она. + System Title Bar takes precedence over this option while it is on. Пока включена «Системная строка заголовка», она имеет приоритет над этим параметром. Value Axis Ось значений On Left Side Слева On Right Side Справа @@ -4948,7 +5033,7 @@ axis Both Оба @msg before must name a panel before должен указывать панель @msg docking action contains an unknown panel or an excessive split path Действие прикрепления содержит неизвестную панель или слишком длинный путь разделений @msg group must be true or false group должен иметь значение true или false -@msg panel is not in a dock group панель не находится в группе дока +@msg panel is not in a dock group panel не находится в группе дока @msg only a dock group above or below others collapses, not one beside another свернуть можно только группу дока над или под другими, а не рядом с ними @msg icon must be true or false icon должен быть true или false @msg icon only docks a panel in the icon column, with no anchor icon только закрепляет панель в столбце значков, без anchor @@ -5115,6 +5200,11 @@ 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 {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: это не изображение PNG, JPEG, WebP, TIFF или BMP +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ размер изображения {iw} × {ih} пикселей, а монтажной области при этой ширине {rw} × {rh} пикселей: их пропорции различаются +@msg `threshold` must be a number from 0 to 1 `threshold` должно быть числом от 0 до 1 +@msg background {v}: a color such as "#808080" фон {v}: цвет, например "#808080" +@msg comparing with an image file isn't available in the browser сравнение с файлом изображения недоступно в браузере @msg an image of {w} × {h} pixels is shown at reduced size изображение размером {w} × {h} пикселей показано в уменьшенном размере @msg the image can't be read as a texture изображение не удаётся прочитать как текстуру Basic Базовый @@ -5313,11 +5403,19 @@ lighting Light Свет @msg too many library panels were opened in this session в этом сеансе открыто слишком много панелей библиотек @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Изображение {_1} × {_2} пикселей, больше {LARGE_TRACE_SIDE} по одной стороне: трассировка выполняется, но медленнее. Стиль с меньшей точностью или уменьшенная копия изображения трассируются быстрее. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Изображение трассировано в размере {_1} × {_2} пикселей, уменьшенное с {_3} × {_4} (не более {_5} мегапикселей). +@msg byColor must be true or false byColor должен иметь значение true или false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor не может разделить группы автогруппировки: трассируйте без автогруппировки +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor не может перегруппировать трассировку с действующим эффектом Pathfinder: разверните без byColor Snapping Привязка Snap to Tangent Привязать к касательной Snap to Perpendicular Привязать к перпендикуляру @msg unknown snap option `{key}` (use {options}) неизвестный параметр привязки `{key}` (допустимо: {options}) Gap Length: Длина зазора: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Режим «Плоский логотип» не использует keepColors: он трассирует цвета, указанные в logoColors, или найденные им цвета. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Ничего не трассировано: ни один из найденных при трассировке цветов не соответствует keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Ничего не трассировано: «Игнорировать цвет» исключает все области цветов, перечисленных в keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Ничего не трассировано: цвета, соответствующие keepColors, не дают ни одного контура. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: ни один из найденных при трассировке цветов не соответствует {c}, поэтому из него ничего не трассируется. @msg `colorGroup` needs `library: "document"` для `colorGroup` нужен `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` для `colorGroup` нужен `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` для `colorGroup` нужен `mode: "color"` @@ -5432,11 +5530,14 @@ axis Y Axis Ось Y @msg Running “{name}”… Выполняется «{name}»… @msg a live effect runs while the document is drawn, so it can't be `heavy` динамический эффект выполняется во время отрисовки документа, поэтому он не может быть `heavy` @msg an exporter always gets an export's budget: leave `budget` out модуль экспорта всегда получает бюджет экспорта: не указывайте `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» запрашивает большой вычислительный бюджет (каждый запуск до {minutes} минут и в {times} раз больше инструкций, чем обычно): установите его с `allowHeavy: true`, когда пользователь согласится +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants «{_1}» запрашивает большой вычислительный бюджет (каждый запуск до {minutes} минут и в {times} раз больше инструкций, чем обычно): его должен одобрить человек в настольном приложении, либо передайте `allowHeavy: true` в сеансе, запущенном с --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Установить «{name}», которому нужны эти разрешения? ID: {id} ID: {id} Version: {version} Версия: {version} + Install “{name}” in place of the plug-in with the same ID? Установить «{name}» вместо модуля с тем же ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Этот файл не является модулем, установленным ранее с этим ID. Он получит настройки и кэш, сохранённые для этого ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees «{name}» отличается от модуля с ID {_1} и получил бы сохранённые для него настройки и кэш: установите его с `replace: true`, когда пользователь согласится File SHA-256: {hash} SHA-256 файла: {hash} Read the objects it is given Читать переданные ему объекты Change, add and delete objects in the document Изменять, добавлять и удалять объекты в документе @@ -5444,21 +5545,43 @@ 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 Записывать документ в файл собственного формата Show its progress, see Cancel and write log lines Показывать свой ход выполнения, замечать отмену и записывать строки журнала -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} разрешению `progress` нужен уровень API модулей {PROGRESS_API}: укажите `api` {PROGRESS_API} + Keep settings of its own Хранить собственные настройки + Keep a cache of its own, which you can clear Хранить собственный кэш, который можно очистить + Clear Plug-in Cache Очистить кэш модуля +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} разрешению `{permission}` нужен уровень API модулей {PROGRESS_API}: укажите `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} модуль импортирует `env.{name}` с другим типом, чем у хоста: у хоста тип {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` модуль импортирует `env.{name}`, который модуль может использовать только на уровне API модулей {PROGRESS_API} (`api` {PROGRESS_API}) и с разрешением `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network модуль импортирует `{from}.{name}`, который хост не предоставляет: модуль может импортировать только `env.vc_progress`, `env.vc_cancelled` и `env.vc_log` (уровень API модулей 2), без WASI, файлов и сети +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` модуль импортирует `env.{name}`, который модуль может использовать только на уровне API модулей {PROGRESS_API} (`api` {PROGRESS_API}) и с разрешением `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network модуль импортирует `{from}.{name}`, который хост не предоставляет: модуль может импортировать только функции `env.vc_*`, перечисленные в docs/plugins.md (уровень API модулей 2), без WASI, файлов и сети @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` равно {_1}; этот хост поддерживает уровни API модулей от 1 до {API_LEVEL} +@msg `paintOutput` may only be true `paintOutput` может быть только true +@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}` @msg at most {MAX_PERMISSIONS} `permissions` не более {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` разрешение `{shown}` не относится к модулю вида `{_1}` @msg permission `{shown}` is listed twice разрешение `{shown}` указано дважды +@msg only a filter declares `inputs` `inputs` может объявлять только фильтр +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance в `inputs` указан неизвестный вид объекта `{shown}`: используйте type, image или symbolInstance +@msg `inputs` lists `{shown}` twice в `inputs` `{shown}` указано дважды +@msg `inputs` needs `api` 2 or later для `inputs` нужен `api` 2 или выше +@msg `inputs` needs the permission `doc.read` для `inputs` нужно разрешение `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) бюджет памяти (объекту {_1} нужно {size} байт входных данных, осталось {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) бюджет объектов (более {MAX_OBJECTS} объектов только для чтения) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it разрешение `{shown}` зарезервировано для будущего API модулей; этот хост его не выдаёт @msg unknown permission `{shown}` неизвестное разрешение `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запрашивает разрешения {asked}: установите его с `grant`, где они перечислены, когда пользователь согласится +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants «{name}» запрашивает разрешения {asked}: их должен одобрить человек в настольном приложении, либо передайте `grant` в сеансе, запущенном с --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` и `allowHeavy` действуют только в сеансе, запущенном с --allow-plugin-grants: одобрите плагин в настольном приложении +@msg Only the person at the keyboard can approve installing a plug-in Одобрить установку плагина может только человек за клавиатурой @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed «{name}» не получил разрешение `{p}`, нужное для его результата: ничего не изменено +@msg a heavy filter runs in the background, so it can't declare `commands` тяжёлый фильтр выполняется в фоне, поэтому не может объявлять `commands` +@msg at most {MAX_COMMANDS} `commands` не более {MAX_COMMANDS} `commands` +@msg command {name} is declared twice команда {name} объявлена дважды +@msg command {name}: `label` must be 1-64 printable characters команда {name}: `label` должен содержать 1-64 печатаемых символа +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter команда {name}: используйте id из 1-{MAX_COMMAND_ID} символов a-z 0-9 _, начинающийся с буквы +@msg command {name}: {e} команда {name}: {e} +@msg only a filter or a live effect declares `commands` `commands` объявляет только фильтр или динамический эффект +@msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed у модуля не тот SHA-256, что указан в `hash`: ничего не установлено # Actions panel: action sets (#3712). New Set… Новый набор… @@ -6242,6 +6365,8 @@ breakText Glyphs Глифы File + Data Set Name Файл + имя набора данных Data Set Name Имя набора данных @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` должно быть `fileNumber`, `fileDataSetName` или `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Каждый набор данных сохраняется поверх одного и того же файла, в котором остаётся только последний набор данных. +@msg save the document with Save As before Save and Close сохраните документ командой «Сохранить как» перед «Сохранить и закрыть» @msg no free data set file name in destination folder В папке назначения нет свободного имени файла для набора данных # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6361,9 +6486,20 @@ breakText Glyphs Глифы Reset Symbol Instance Сбросить экземпляр символа @msg type must be static or dynamic, not {v} type должен быть static или dynamic, а не {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` — статический символ: сначала задайте для него тип символа «Динамический» +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change У контура, составного контура или текста, выделенного в экземпляре символа, можно изменить только сплошной цвет заливки и обводки @msg path must be a list of child indices path должен быть списком индексов дочерних объектов @msg no object at path {shown} in the symbol's art в графике символа нет объекта по пути {shown} @msg give fill, stroke, opacity and/or blend укажите fill, stroke, opacity и/или blend +@msg {key} cannot be set on a guide {key} нельзя задать для направляющей +@msg {key} cannot be set on a blend {key} нельзя задать для перехода +@msg {key} cannot be set on an envelope {key} нельзя задать для оболочки +@msg {key} cannot be set on a gradient mesh {key} нельзя задать для градиентной сетки +@msg {key} cannot be set on a Repeat {key} нельзя задать для повторения +@msg {key} cannot be set on a placed document {key} нельзя задать для помещенного документа +@msg {key} cannot be set on an Intertwine group {key} нельзя задать для группы переплетения +@msg stroke cannot be set on a symbol instance stroke нельзя задать для экземпляра символа +@msg fill on a symbol instance must be a solid color or none fill экземпляра символа должен быть сплошным цветом или none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} нельзя задать для изображения без эффекта «Преобразовать в фигуру» или «Обвести объект» @msg pass newName, registration, exportType, nineSlice and/or type укажите newName, registration, exportType, nineSlice и/или type Text Flow Поток текста By Rows По строкам @@ -6505,6 +6641,7 @@ breakText Glyphs Глифы digits Hindi Хинди font-script Japanese Японский shape Open Открытый + Help Bar Панель подсказок # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Инструменты текста Text Tweaker… Настройка текста… @@ -6570,3 +6707,44 @@ 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 plug-in of kind `{_2}`, not an importer `{_1}` является модулем вида `{_2}`, а не модулем импорта +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_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` + +# Safe Mode + Safe Mode… Безопасный режим… + Safe Mode Безопасный режим + Help › Safe Mode… Справка › Безопасный режим… + Enable on Re-launch Включить при следующем запуске + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Последний запуск VectorCraft остановился во время загрузки модуля {name}. Возможно, причина не в нём. + Disable and Continue Отключить и продолжить + Start Normally Запустить в обычном режиме + Start in Safe Mode Запустить в безопасном режиме + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft работает в безопасном режиме: модули не загружаются и не выполняются, пока программа не будет запущена в обычном режиме. + Disabled plug-ins Отключённые модули + No plug-ins are disabled. Нет отключённых модулей. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Безопасный режим включён: модули не загружаются и не выполняются, пока VectorCraft не будет запущен в обычном режиме +@msg give `id` or `file` (see plugin.safeMode) укажите `id` или `file` (см. plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) этот модуль не отключён (см. plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch Не удалось сохранить отключённые модули для следующего запуска +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Разбор оформления: эффект модуля в выделении нельзя разобрать в безопасном режиме + +# Align to Pixel Grid + Align to Pixel Grid Выровнять по пиксельной сетке +@msg only paths and compound paths can be aligned to the pixel grid по пиксельной сетке можно выровнять только контуры и составные контуры +@msg side is left or right, not `{s}` side — это left или right, а не `{s}` diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 58ceab701..b857c0bd2 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -186,6 +186,10 @@ Anti-Alias Kantutjämning Anti-alias Rasters Kantutjämna rasterbilder Anti-aliased Artwork Kantutjämnad grafik + Anti-aliasing Method Kantutjämningsmetod +antiAlias Sharp Skarp + Crisp Klar + Strong Kraftig Anti-aliasing: Kantutjämning: Any Kind Alla typer Font Technology Typsnittsteknik @@ -356,6 +360,13 @@ font-technology Variable Variabla Apply Bullets Använd punkter Numbered List Numrerad lista Apply Numbers Använd numrering + Bullets and Numbering… Punkter och numrering… + Numbers Nummer + Level: Nivå: + Bullet Character: Punkttecken: + Number Style: Nummerformat: + Left Indent: Vänsterindrag: + First Line Indent: Indrag för första raden: Convert to Text Konvertera till text Filled Dot Fylld punkt Dash Tankstreck @@ -542,6 +553,11 @@ font-technology Variable Variabla Color Table ({n} colors) Färgtabell ({n} färger) Color Theme Färgtema Color Themes Färgteman + Color Wheel Färghjul + Hue ring Nyansring + Saturation and brightness Mättnad och ljusstyrka + Color field Färgfält + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Färgtyp: Color: Färg: Colorize Färglägg @@ -1065,6 +1081,14 @@ font-technology Variable Variabla Export As SVG… Exportera som SVG… Export Asset Exportera resurs Export Assets Exportera resurser + Export 3D Object Exportera 3D-objekt + 3D Export Format 3D-exportformat + Export 3D Assets Exportera 3D-resurser +@msg {what} can't be exported in 3D: {e} {what} kan inte exporteras i 3D: {e} +@msg asset {id} isn't a 3D asset resurs {id} är ingen 3D-resurs +@msg asset {id} is a 3D asset: assets.export3D writes it resurs {id} är en 3D-resurs: assets.export3D skriver den +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) inga 3D-resurser än: samla först ett objekt med en 3D-effekt (assets.add3D) +@msg select an object with a 3D effect (or give its ids) markera ett objekt med en 3D-effekt (eller ange dess id:n) Export As… Exportera som… Export CSS Exportera CSS Export CSS… Exportera CSS… @@ -1096,6 +1120,17 @@ font-technology Variable Variabla Exporting {} Exporterar {} Export… Exportera… Extrude & Bevel… Extrudera och fasa… + 3D (Classic) 3D (klassisk) + Extrude & Bevel (Classic)… Extrudera och fasa (klassisk)… + Surface Yta + Wireframe Trådram + No Shading Ingen skuggning + Plastic Plast + Highlight Intensity Högdagerintensitet + Highlight Size Högdagerstorlek + Blend Steps Övergångssteg + Shading Color Skuggningsfärg + Draw Hidden Faces Rita dolda ytor Eyedropper Pipett Eyedropper Applies: Pipetten tillämpar: Eyedropper Options Pipettalternativ @@ -1138,6 +1173,7 @@ font-technology Variable Variabla Live curve fitting Kurvanpassning medan du ritar Fill Color Fyllnadsfärg Fill New Type Objects With Placeholder Text Fyll nya textobjekt med platshållartext + Start Lists From Typed Markers Starta listor från inskrivna punkttecken Fill Rule Fyllningsregel Fill with Placeholder Text Fyll med platshållartext Fill: Fyllning: @@ -1148,6 +1184,7 @@ font-technology Variable Variabla Find Font… Sök typsnitt… Find Next Sök nästa Find Objects Sök objekt + Compare with Image Jämför med bild First Page Första sidan First-line Left Indent Vänsterindrag för första raden Fisheye… Fisköga… @@ -1670,6 +1707,7 @@ font-technology Variable Variabla Report copied Rapporten har kopierats Line {line} Rad {line} Line {line}: {name}: {status} Rad {line}: {name}: {status} + Read Action Set File Läs fil med åtgärdsuppsättning Read Shortcut Set File Läs fil med kortkommandon Import Matches Importera matchningar Remember Import Matches Kom ihåg importmatchningar @@ -1904,6 +1942,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 @@ -2699,6 +2741,7 @@ digits Malayalam Malayalam Reset Transformation Återställ transformering Replace With Font Ersätt med typsnitt Replace With Font From Ersätt med typsnitt från + Find In Sök i System System Replaces the saved preset of that name. Ersätter den sparade förinställningen med samma namn. Replace… Ersätt… @@ -2984,6 +3027,11 @@ digits Malayalam Malayalam Selected Swatches Markerade färgrutor Selected Swatches Only Endast markerade färgrutor Selection Markering +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Rektangelmarkering + Touching Berörda objekt + Fully Enclosed Helt omslutna + Drag Direction Dragriktning Selection & Anchor Display Visning av markering och ankarpunkter Selection Only Endast markering Selection Tool Markeringsverktyg @@ -3119,6 +3167,7 @@ digits Malayalam Malayalam Simulate Colored Paper Simulera färgat papper Simulate Paper Simulera papper Double Column Dubbel kolumn + Keep Tools Beside the Canvas Verktyg bredvid arbetsytan Single Left Quotation Mark Enkelt vänster citattecken Single Quotes: Enkla citattecken: Single Right Quotation Mark Enkelt höger citattecken @@ -3367,6 +3416,15 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Inställningar för Tate-chu-yoko… Tate-chu-yoko Settings Inställningar för Tate-chu-yoko + Composite Fonts… Sammansatta teckensnitt… + Composite Fonts Sammansatta teckensnitt + Export Composite Fonts Exportera sammansatta teckensnitt + Import Composite Fonts Importera sammansatta teckensnitt + Delete Font Ta bort teckensnitt + Kanji Kanji + Kana Kana + Numerals Siffror + Scale at Center Skala från mitten Up/Down: Upp/ned: Left/Right: Vänster/höger: Temperature Temperatur @@ -3420,6 +3478,7 @@ digits Malayalam Malayalam Tile Type: Ruttyp: tile {number} ruta {number} Tiles are numbered across, then down; leave the range empty for every tile. Rutorna numreras från vänster till höger och sedan nedåt. Lämna omfånget tomt för att få med alla rutor. + Copies per Sheet (as many as fit, in a grid) Kopior per ark (så många som får plats, i ett rutnät) Tint Färgton Tip extends past the end point Spetsen sträcker sig förbi slutpunkten Tip on the end point Spetsen på slutpunkten @@ -3714,6 +3773,7 @@ digits Malayalam Malayalam Zoom to Selection Zooma till markering Zoom Tool Zoomverktyg Zoom with Mouse Wheel Zooma med mushjulet + Scroll Sideways with Cmd/Ctrl + Wheel Rulla horisontellt med Cmd/Ctrl + mushjulet Scrub Numeric Fields by Dragging Ändra numeriska fält genom att dra @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} fil i {folder} {note}. Visa paketmappen?|{n} filer i {folder} {note}. Visa paketmappen? @plural {n} linked file not found|{n} linked files not found {n} länkad fil hittades inte|{n} länkade filer hittades inte @@ -3878,11 +3938,11 @@ axis Both Båda Dock to Bottom Docka nederst Dock Control Bar Docka kontrollfältet Drag to float the toolbar Dra för att göra verktygsfältet flytande - Drag to the window's left edge to dock Dra till fönstrets vänsterkant för att docka + Drag to the window's left or right edge to dock Dra till fönstrets vänster- eller högerkant för att docka Float Tool Group Gör verktygsgruppen flytande @msg no library `{id}` inget bibliotek `{id}` @msg select some type first markera någon text först -@msg kerning must be a number, "auto", or "optical" Kerning måste vara ett tal, ”auto” eller ”optical” +@msg kerning must be a number, "auto", or "optical" kerning måste vara ett tal, ”auto” eller ”optical” @msg the selection has no gradient fill markeringen har ingen fyllnadsgradient @msg the selection has no gradient stroke markeringen har ingen konturgradient @msg the selection has no pattern fill markeringen har inget fyllnadsmönster @@ -3985,6 +4045,7 @@ axis Both Båda @msg Targa files are RGB: colorModel rgb Targa-filer är RGB: colorModel rgb @msg Text Wrap: select a wrap object Textombrytning: markera ett ombrytningsobjekt @msg This shortcut file is in a form VectorCraft can't read. Den här kortkommandofilen har ett format som VectorCraft inte kan läsa. +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` måste vara "none", "sharp", "crisp" eller "strong" @msg `aspect` must be a number (%) `aspect` måste vara ett tal (%) @msg `at` must be [x, y] `at` måste vara [x, y] @msg `color` must be a colour `color` måste vara en färg @@ -4102,7 +4163,7 @@ axis Both Båda @msg bad shortcut ogiltigt kortkommando @msg bad stream length ogiltig strömlängd @msg bad trailer ogiltig trailer -@msg balance must be between 0 and 100 balansen måste vara mellan 0 och 100 +@msg balance must be between 0 and 100 balance måste vara mellan 0 och 100 @msg batch step {_1} (`{id}`) failed: {e} batchsteg {_1} (`{id}`) misslyckades: {e} @msg brightness must be dark|mediumDark|mediumLight|light brightness måste vara dark|mediumDark|mediumLight|light @msg can't encode the document: {0} kan inte koda dokumentet: {0} @@ -4129,7 +4190,7 @@ axis Both Båda @msg compound paths can only contain paths sammansatta banor kan bara innehålla banor @msg compressed (RLE) bitmaps can't be read yet komprimerade (RLE) bitmappar kan inte läsas än @msg damaged skadad -@msg decimals must be {_1}–{_2} (got {_3}) decimaler måste vara {_1}–{_2} (fick {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals måste vara {_1}–{_2} (fick {_3}) @msg divide into 1 to {MAX_PIECES} slices dela i 1 till {MAX_PIECES} skivor @msg effect {k} is `{_1}`, not `{id}` effekt {k} är `{_1}`, inte `{id}` @msg empty brush stroke tomt penseldrag @@ -4254,6 +4315,8 @@ axis Both Båda @msg no such action set åtgärdsuppsättningen finns inte @msg the action sets changed; nothing was deleted åtgärdsuppsättningarna har ändrats; inget raderades @msg no action set to save ingen åtgärdsuppsättning att spara +@msg the action set file can't be read in full filen med åtgärdsuppsättning kan inte läsas helt +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Läste in ”{name}”: {n} steg utelämnades|Läste in ”{name}”: {n} steg utelämnades @msg not an action set file inte en fil med åtgärdsuppsättning @msg the action set file is larger than 8 MiB filen med åtgärdsuppsättningen är större än 8 MiB @msg the action set file holds more than 1,000 actions filen med åtgärdsuppsättningen innehåller fler än 1 000 åtgärder @@ -4328,7 +4391,7 @@ axis Both Båda @msg replace: the object to replace was changed or deleted replace: objektet som skulle ersättas har ändrats eller tagits bort @msg pattern missing mönster saknas @msg patterns nested too deeply are filled with mid-grey mönster som är kapslade för djupt fylls med mellangrått -@msg percent must be above 0 and at most 6400, not {p} procent måste vara över 0 och högst 6400, inte {p} +@msg percent must be above 0 and at most 6400, not {p} percent måste vara över 0 och högst 6400, inte {p} @msg pick a colour, gradient or pattern to save as a swatch välj en färg, gradient eller ett mönster att spara som färgruta @msg plane must be left, right or ground plane måste vara left, right eller ground @msg plug-in ABI: {0} pluginets ABI: {0} @@ -4349,8 +4412,8 @@ axis Both Båda @msg result would exceed the canvas (coordinates out of range) resultatet skulle hamna utanför arbetsytan (koordinater utanför intervallet) @msg objects can't be nested more than 32 deep objekt kan inte kapslas djupare än 32 nivåer @msg the SVG's elements are nested more than {0} deep SVG-filens element är nästlade mer än {0} nivåer djupt -@msg scale must be a number from {_1} to {_2}, not {_3} skala måste vara ett tal från {_1} till {_2}, inte {_3} -@msg scale must be a number from {_1} to {_2}, not {s} skala måste vara ett tal från {_1} till {_2}, inte {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale måste vara ett tal från {_1} till {_2}, inte {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale måste vara ett tal från {_1} till {_2}, inte {s} @msg select (or target) objects whose appearance isn't basic markera (eller ange som mål) objekt vars utseende inte är grundläggande @msg select a Live Paint group markera en Live Paint-grupp @msg select a blend markera en blandning @@ -4372,6 +4435,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 @@ -4609,11 +4679,15 @@ axis Both Båda @msg its objects are nested too deeply dess objekt är för djupt kapslade @msg its section ending with `%{end}` doesn't end dess avsnitt som slutar med `%{end}` avslutas aldrig @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) penslar som VectorCraft ännu inte kan läsa utelämnades från panelen Penslar ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekt med flera fyllningar eller konturer, effekter eller penslar importerades som sitt ritade utseende ({_1}): deras utseende går inte att redigera än +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekt med flera fyllningar eller konturer, konturer med variabel bredd, effekter eller penslar importerades som sitt ritade utseende ({_1}): deras utseende går inte att redigera än +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding namnet `{name}` är inte UTF-8 och lästes som {label}: om det ser fel ut, öppna filen med en annan legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding namnet `{name}` är inte UTF-8, och delar av det är inte heller giltig {label}: öppna filen med en annan legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) fler namn som inte är UTF-8 lästes som {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradienten ”{name}” är inte definierad i filen: en enfärgad fyllning används i stället @msg an image VectorCraft can't read was left out en bild som VectorCraft inte kan läsa utelämnades @msg graphs come in as plain art, without their data or settings diagram läses in som vanlig grafik, utan sina data och inställningar @msg an image whose data is cut short was left out en bild med avkortade data utelämnades +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers en inbäddad bild på {_1} × {_2} pixlar förminskades till {_3} × {_4} pixlar för att behålla lagren @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel länkade bilder visar förhandsgranskningen med låg upplösning som sparats i filen tills deras filer hittas: länka om dem i panelen Länkar @msg linked type areas past VectorCraft's limits were left out länkade textområden utöver VectorCrafts gränser utelämnades @msg type that changes writing direction inside a text object opens in the direction of its first character text som byter skrivriktning inuti ett textobjekt öppnas i riktningen för sitt första tecken @@ -4693,6 +4767,7 @@ axis Both Båda @msg object ids are non-negative integers, not {v} objekt-id:n är icke-negativa heltal, inte {v} @msg {key} must be an array of non-negative integer object ids {key} måste vara en array med objekt-id:n som är icke-negativa heltal @msg invalid object id {value}: expected a non-negative integer ogiltigt objekt-id {value}: ett icke-negativt heltal förväntades +@msg `{key}` entry {index} is {value}, not a non-negative integer object id post {index} i `{key}` är {value}, inte ett objekt-id (ett icke-negativt heltal) @msg no such object id {value} det finns inget objekt med id {value} @msg id {value} is a layer, not an object id {value} är ett lager, inte ett objekt @msg id {value} is a layer, not an object; move layers with layer.move id {value} är ett lager, inte ett objekt; flytta lager med layer.move @@ -4719,12 +4794,19 @@ axis Both Båda @msg the Pixar file's image data is damaged Pixar-filens bilddata är skadade @msg the JPEG 2000 file can't be read JPEG 2000-filen kan inte läsas @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000-filen har en färgrymd eller ett JPX-innehåll som inte stöds +@msg the HEIC or HEIF file can't be read HEIC- eller HEIF-filen kan inte läsas +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC- eller HEIF-filen innehåller en typ av bild som inte kan läsas +@msg HEIC and HEIF images can't be opened in this build HEIC- och HEIF-bilder kan inte öppnas i den här versionen @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD-bilden är för stor för att läsas ({_1} × {_2} pixlar) @msg zipped 32-bit PSD image data with prediction can't be read zippad 32-bitars PSD-bilddata med prediktion kan inte läsas @msg {depth}-bit PSD files in this colour mode can't be read {depth}-bitars PSD-filer i det här färgläget kan inte läsas @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 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 De-Interlace… Avfläta… De-Interlace Avfläta @@ -4781,6 +4863,9 @@ axis Both Båda effect Soft Mjuk Edge Kant System Title Bar Systemets namnlist + VectorCraft Title Bar on Every Desktop VectorCrafts namnlist på alla skrivbord + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Miljövariabeln VECTORCRAFT_TITLEBAR väljer namnlisten så länge den är angiven. + System Title Bar takes precedence over this option while it is on. Systemets namnlist har företräde framför det här alternativet så länge den är på. Value Axis Värdeaxel On Left Side På vänster sida On Right Side På höger sida @@ -4826,7 +4911,7 @@ effect Soft Mjuk @msg before must name a panel before måste ange en panel @msg docking action contains an unknown panel or an excessive split path dockningsåtgärden innehåller en okänd panel eller en alltför djup delningssökväg @msg group must be true or false group måste vara true eller false -@msg panel is not in a dock group panelen ligger inte i en dockningsgrupp +@msg panel is not in a dock group panel ligger inte i en dockningsgrupp @msg only a dock group above or below others collapses, not one beside another bara en dockningsgrupp ovanför eller under andra komprimeras, inte en bredvid en annan @msg icon must be true or false icon måste vara true eller false @msg icon only docks a panel in the icon column, with no anchor icon dockar bara en panel i ikonkolumnen, utan anchor @@ -5412,6 +5497,11 @@ mark-style Japanese Japanska theme Appearance Utseende Load EPS into Clipboard Läs in EPS till urklipp @msg an image of {w} × {h} pixels is too large to draw en bild på {w} × {h} pixlar är för stor för att ritas +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: inte en PNG-, JPEG-, WebP-, TIFF- eller BMP-bild +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ bilden är {iw} × {ih} pixlar och ritytan i den bredden är {rw} × {rh} pixlar: proportionerna skiljer sig åt +@msg `threshold` must be a number from 0 to 1 `threshold` måste vara ett tal från 0 till 1 +@msg background {v}: a color such as "#808080" background {v}: en färg som "#808080" +@msg comparing with an image file isn't available in the browser jämförelse med en bildfil är inte tillgänglig i webbläsaren @msg an image of {w} × {h} pixels is shown at reduced size en bild på {w} × {h} pixlar visas i förminskad storlek Fill and Stroke Controls Kontroller för fyllning och kontur Coloring Controls Kontroller för färgläggning @@ -5727,11 +5817,19 @@ swatch Yellow Green Gulgrön @msg “{name}” is not a file ”{name}” är inte en fil @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Bilden är {_1} × {_2} pixlar, mer än {LARGE_TRACE_SIDE} på en sida: den vektoriseras, men långsammare. En förinställning med lägre återgivning eller en mindre kopia av bilden går snabbare. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Bilden vektoriserades vid {_1} × {_2} pixlar, nedskalad från {_3} × {_4} (högst {_5} megapixlar). +@msg byColor must be true or false byColor måste vara true eller false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor kan inte dela upp grupper från Automatisk gruppering: vektorisera utan Automatisk gruppering +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor kan inte gruppera om en kalkering med en aktiv Pathfinder-effekt: expandera utan byColor Snapping Fäst Snap to Tangent Fäst mot tangent Snap to Perpendicular Fäst mot vinkelrät @msg unknown snap option `{key}` (use {options}) okänt fästalternativ `{key}` (använd {options}) Gap Length: Lucklängd: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Läget Platt logotyp använder inte keepColors: det vektoriserar färgerna som anges i logoColors eller färgerna det hittar. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Ingenting vektoriserades: ingen av färgerna som vektoriseringen hittade motsvarar keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Ingenting vektoriserades: Ignorera färg utelämnar alla områden med färgerna som keepColors anger. +@msg Nothing was traced: the colors that match keepColors produce no paths. Ingenting vektoriserades: färgerna som matchar keepColors ger inga banor. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: ingen av färgerna som vektoriseringen hittade motsvarar {c}, så ingenting av den vektoriseras. @msg `colorGroup` needs `library: "document"` `colorGroup` kräver `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` kräver `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` kräver `mode: "color"` @@ -5844,11 +5942,14 @@ axis Y Axis Y-axel @msg Running “{name}”… Kör ”{name}”… @msg a live effect runs while the document is drawn, so it can't be `heavy` en live-effekt körs medan dokumentet ritas, så den kan inte vara `heavy` @msg an exporter always gets an export's budget: leave `budget` out ett exportinsticksprogram får alltid en exports budget: utelämna `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees ”{_1}” begär en stor beräkningsbudget (varje körning upp till {minutes} minuter och {times} gånger de vanliga instruktionerna): installera det med `allowHeavy: true` när användaren har godkänt +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants ”{_1}” begär en stor beräkningsbudget (varje körning upp till {minutes} minuter och {times} gånger de vanliga instruktionerna): en person måste godkänna det i skrivbordsappen, eller skicka `allowHeavy: true` i en session som startats med --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Installera ”{name}”, som begär dessa behörigheter? ID: {id} ID: {id} Version: {version} Version: {version} + Install “{name}” in place of the plug-in with the same ID? Installera ”{name}” i stället för det plugin som har samma id? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Den här filen är inte det plugin som tidigare installerades med detta id. Den får de inställningar och den cache som sparats för detta id. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees ”{name}” är en annan modul än pluginet med id {_1} och skulle få de inställningar och den cache som sparats för det: installera det med `replace: true` när användaren har godkänt File SHA-256: {hash} Filens SHA-256: {hash} Read the objects it is given Läsa objekten den får Change, add and delete objects in the document Ändra, lägga till och ta bort objekt i dokumentet @@ -5856,21 +5957,43 @@ 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 Show its progress, see Cancel and write log lines Visa sitt förlopp, se när det avbryts och skriva loggrader -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} behörigheten `progress` kräver plugin-API-nivå {PROGRESS_API}: ange `api` {PROGRESS_API} + Keep settings of its own Spara egna inställningar + Keep a cache of its own, which you can clear Spara en egen cache, som du kan tömma + Clear Plug-in Cache Töm pluginets cache +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} behörigheten `{permission}` kräver plugin-API-nivå {PROGRESS_API}: ange `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} modulen importerar `env.{name}` med en annan typ än värdens: värdens typ är {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modulen importerar `env.{name}`, som ett plugin bara får använda på plugin-API-nivå {PROGRESS_API} (`api` {PROGRESS_API}) och med behörigheten `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modulen importerar `{from}.{name}`, som värden inte erbjuder: ett plugin får bara importera `env.vc_progress`, `env.vc_cancelled` och `env.vc_log` (plugin-API-nivå 2), och ingen WASI, inga filer eller nätverk +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` modulen importerar `env.{name}`, som ett plugin bara får använda på plugin-API-nivå {PROGRESS_API} (`api` {PROGRESS_API}) och med behörigheten `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network modulen importerar `{from}.{name}`, som värden inte erbjuder: ett plugin får bara importera de `env.vc_*`-funktioner som docs/plugins.md listar (plugin-API-nivå 2), och ingen WASI, inga filer eller nätverk @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` may only be true `paintOutput` får bara vara true +@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}` @msg at most {MAX_PERMISSIONS} `permissions` högst {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` behörigheten `{shown}` gäller inte ett plugin av typen `{_1}` @msg permission `{shown}` is listed twice behörigheten `{shown}` står två gånger +@msg only a filter declares `inputs` bara ett filter anger `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` anger en okänd objekttyp `{shown}`: använd type, image eller symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` anger `{shown}` två gånger +@msg `inputs` needs `api` 2 or later `inputs` kräver `api` 2 eller senare +@msg `inputs` needs the permission `doc.read` `inputs` kräver behörigheten `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) minnesbudget (objekt {_1} behöver {size} byte indata, {left} återstår) +@msg object budget (more than {MAX_OBJECTS} read-only objects) objektbudget (fler än {MAX_OBJECTS} skrivskyddade objekt) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it behörigheten `{shown}` är reserverad för ett senare plugin-API; den här värden beviljar den inte @msg unknown permission `{shown}` okänd behörighet `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” begär behörigheterna {asked}: installera det med en `grant` som listar dem när användaren har godkänt +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants ”{name}” begär behörigheterna {asked}: en person måste godkänna dem i skrivbordsappen, eller skicka `grant` i en session som startats med --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` och `allowHeavy` gäller bara i en session som startats med --allow-plugin-grants: godkänn insticksprogrammet i skrivbordsappen +@msg Only the person at the keyboard can approve installing a plug-in Bara personen vid tangentbordet kan godkänna installationen av ett insticksprogram @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” beviljades inte `{p}`, som dess utdata behöver: ingenting ändrades +@msg a heavy filter runs in the background, so it can't declare `commands` ett tungt filter körs i bakgrunden, så det kan inte deklarera `commands` +@msg at most {MAX_COMMANDS} `commands` högst {MAX_COMMANDS} `commands` +@msg command {name} is declared twice kommandot {name} deklareras två gånger +@msg command {name}: `label` must be 1-64 printable characters kommando {name}: `label` måste ha 1-64 utskrivbara tecken +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter kommando {name}: använd ett id med 1-{MAX_COMMAND_ID} tecken av a-z 0-9 _ som börjar med en bokstav +@msg command {name}: {e} kommando {name}: {e} +@msg only a filter or a live effect declares `commands` bara ett filter eller en live-effekt deklarerar `commands` +@msg “{name}” › {label}: {e} ”{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modulen har inte den SHA-256 som `hash` anger: ingenting installerades # Actions panel: action sets (#3712). New Set… Ny uppsättning… @@ -6317,6 +6440,8 @@ breakText Glyphs Glyfer File + Data Set Name Fil + datauppsättningens namn Data Set Name Datauppsättningens namn @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` måste vara `fileNumber`, `fileDataSetName` eller `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Varje datauppsättning sparas över samma fil, som bara behåller den sista datauppsättningen. +@msg save the document with Save As before Save and Close spara dokumentet med Spara som före Spara och stäng @msg no free data set file name in destination folder Inget ledigt filnamn för datauppsättningen i målmappen # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6436,9 +6561,20 @@ breakText Glyphs Glyfer Reset Symbol Instance Återställ symbolinstans @msg type must be static or dynamic, not {v} type måste vara static eller dynamic, inte {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` är en statisk symbol: ställ först in symboltypen på Dynamisk +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change För en bana, sammansatt bana eller text som är markerad i en symbolinstans kan bara enfärgad fyllning och linje ändras @msg path must be a list of child indices path måste vara en lista med underordnade index @msg no object at path {shown} in the symbol's art inget objekt vid sökvägen {shown} i symbolens bild @msg give fill, stroke, opacity and/or blend ange fill, stroke, opacity och/eller blend +@msg {key} cannot be set on a guide {key} kan inte anges för en stödlinje +@msg {key} cannot be set on a blend {key} kan inte anges för en blandning +@msg {key} cannot be set on an envelope {key} kan inte anges för ett kuvert +@msg {key} cannot be set on a gradient mesh {key} kan inte anges för ett gradientnät +@msg {key} cannot be set on a Repeat {key} kan inte anges för en upprepning +@msg {key} cannot be set on a placed document {key} kan inte anges för ett placerat dokument +@msg {key} cannot be set on an Intertwine group {key} kan inte anges för en sammanflätad grupp +@msg stroke cannot be set on a symbol instance stroke kan inte anges för en symbolinstans +@msg fill on a symbol instance must be a solid color or none fill för en symbolinstans måste vara en enfärgad färg eller none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} kan inte anges för en bild utan effekten Konvertera till form eller Konvertera objekt till konturer @msg pass newName, registration, exportType, nineSlice and/or type ange newName, registration, exportType, nineSlice och/eller type Text Flow Textflöde By Rows Radvis @@ -6472,6 +6608,7 @@ breakText Glyphs Glyfer # The strings the report listed as missing for sv. Font Size: Teckenstorlek: digits Hindi Hindi + Help Bar Hjälpfält # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Textverktyg Text Tweaker… Textjustering… @@ -6643,7 +6780,7 @@ digits Hindi Hindi @msg {what} points outside memory {what} pekar utanför minnet @msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} skulle skrivas ut på fler än {MAX_TILES} rutor: skala ned det @msg `{_1}` can't be edited or removed `{_1}` kan inte redigeras eller tas bort -@msg `{_1}` is a {_2}, not an exporter `{_1}` är av typen {_2}, inte en exportfunktion +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` är ett plugin av typen `{_2}`, inte en exportfunktion @msg `{_1}` is a live effect: apply it with effect.apply {{"effect"}"}} `{_1}` är en live-effekt: använd den med effect.apply {{"effect"}"}} @msg `{_1}` is an exporter: export with document.export {{"format"}"}} `{_1}` är en exportfunktion: exportera med document.export {{"format"}"}} @msg `{_1}` is built in and always listed `{_1}` är inbyggd och listas alltid @@ -7286,7 +7423,7 @@ digits Hindi Hindi @msg margin: a {m} pt margin leaves no imageable area on the paper margin: en marginal på {m} pt lämnar ingen tryckbar yta på papperet @msg matrix must be finite and invertible, depth finite matrix måste vara ändlig och inverterbar, depth ändligt @msg matte: {e} bakgrundsfärg: {e} -@msg member is for order front, forward, backward or back member gäller ordningen front, forward, backward eller back +@msg member is for order front, forward, backward or back member gäller order front, forward, backward eller back @msg memory budget (the selection is too large to hand over) minnesbudget (markeringen är för stor för att lämnas över) @msg missing `{k}` `{k}` saknas @msg missing `{key}` `{key}` saknas @@ -7518,7 +7655,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) @@ -7555,7 +7692,7 @@ digits Hindi Hindi @msg page art is missing image data for {key} sidans bildmaterial saknar bilddata för {key} @msg page art is missing pattern {name} sidans bildmaterial saknar mönstret {name} @msg page art is missing swatch {name} sidans bildmaterial saknar färgrutan {name} -@msg page must be a whole number from 1 sidan måste vara ett heltal från och med 1 +@msg page must be a whole number from 1 page måste vara ett heltal från och med 1 @msg page thumbnail: {e} sidminiatyr: {e} @msg pages must be a range such as "2-3" or a page number, not {v} pages måste vara ett intervall som "2-3" eller ett sidnummer, inte {v} @msg pages: {e} sidor: {e} @@ -7565,7 +7702,7 @@ digits Hindi Hindi @msg parameters must be an object parametrarna måste vara ett objekt @msg passwords can have up to {MAX_PASSWORD} bytes lösenord får vara högst {MAX_PASSWORD} byte @msg path has no open end banan har ingen öppen ände -@msg paths must be strings sökvägar måste vara strängar +@msg paths must be strings paths måste vara strängar @msg PDF content is incomplete or exceeds the reader's limits; no document was imported PDF-innehållet är ofullständigt eller överskrider läsarens gränser; inget dokument importerades @msg PDF flattener: {e} PDF-plattning: {e} @msg PDF options: {e} PDF-alternativ: {e} @@ -7854,7 +7991,7 @@ digits Hindi Hindi @msg thumbnail: no page {v} (the {what} has {count}) miniatyr: ingen sida {v} ({what} har {count}) @msg tileRange of {what}: {e} tileRange för {what}: {e} @msg time budget tidsbudget -@msg title can't be empty titeln får inte vara tom +@msg title can't be empty title får inte vara tom @msg to (the new name) is required to (det nya namnet) krävs @msg to must be fill or stroke to måste vara fill eller stroke @msg to must be smooth or corner to måste vara smooth eller corner @@ -7960,7 +8097,7 @@ digits Hindi Hindi @msg view must be one of {_1} view måste vara något av {_1} @msg warpStyle can only edit an applied Warp effect warpStyle kan bara redigera en tillämpad Förvräng-effekt @msg width factors must be finite breddfaktorerna måste vara ändliga -@msg width must be positive bredden måste vara positiv +@msg width must be positive width måste vara positiv @msg width out of range bredden ligger utanför tillåtet omfång @msg width/height must be > 0 bredd/höjd måste vara > 0 @msg width/height must be positive and within the canvas bredd/höjd måste vara positiva och inom arbetsytan @@ -7968,3 +8105,42 @@ 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 plug-in of kind `{_2}`, not an importer `{_1}` är ett plugin 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` + +# Safe Mode + Safe Mode… Felsäkert läge… + Safe Mode Felsäkert läge + Help › Safe Mode… Hjälp › Felsäkert läge… + Enable on Re-launch Aktivera vid nästa start + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Den senaste starten av VectorCraft stoppades medan pluginet {name} lästes in. Det är kanske inte orsaken. + Disable and Continue Inaktivera och fortsätt + Start Normally Starta normalt + Start in Safe Mode Starta i felsäkert läge + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft är i felsäkert läge: inga plugins läses in eller körs förrän programmet startas normalt. + Disabled plug-ins Inaktiverade plugins + No plug-ins are disabled. Inga plugins är inaktiverade. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Felsäkert läge är på: plugins läses inte in eller körs förrän VectorCraft startas normalt +@msg give `id` or `file` (see plugin.safeMode) ange `id` eller `file` (se plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) det pluginet är inte inaktiverat (se plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch De inaktiverade pluginen kunde inte sparas till nästa start +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Expandera utseende: en plugin-effekt i markeringen kan inte expanderas i felsäkert läge + +# Align to Pixel Grid + Align to Pixel Grid Justera mot pixelrutnät +@msg only paths and compound paths can be aligned to the pixel grid bara banor och sammansatta banor kan justeras mot pixelrutnätet +@msg side is left or right, not `{s}` side är left eller right, inte `{s}` diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index ed95d4e8e..7c4df674d 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -184,6 +184,29 @@ fn font_height_reference_labels_are_in_complete_catalogs() { } } +/// Preference choices and the marquee's E hint share the same translated labels. +#[test] +fn marquee_selection_labels_are_in_complete_catalogs() { + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + for label in ["Marquee Selection", "Touching", "Fully Enclosed", "Drag Direction"] { + assert!(lang.catalog().plain(label).is_some(), "{} is missing {label}", lang.code); + } + } +} + +/// The Character panel's Anti-aliasing Method label and its choices, with the Sharp method's +/// own `antiAlias` row apart from the plain "Sharp" corner row. +#[test] +fn anti_aliasing_method_labels_are_in_complete_catalogs() { + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + let cat = lang.catalog(); + for label in ["Anti-aliasing Method", "Crisp", "Strong"] { + assert!(cat.plain(label).is_some(), "{} is missing {label}", lang.code); + } + assert!(cat.contextual("antiAlias", "Sharp").is_some(), "{} is missing the antiAlias Sharp row", lang.code); + } +} + /// Each language counts with its own plural rule: the form a count picks (`one`, then `few`, then /// `other`/`many`). #[test] @@ -1574,6 +1597,28 @@ 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); + // 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}"); + } + } +} + #[test] fn package_status_message_templates_parse() { let c = Catalog::parse(concat!( @@ -1664,9 +1709,10 @@ const COUNTED_STATUS_MESSAGES: &[(&str, &str)] = &[ ("Copied the CSS of 1 object", "Copied the CSS of 2 objects"), ("Downloaded 1 file", "Downloaded 2 files"), ("Exported 1 file to /out", "Exported 2 files to /out"), + ("Loaded “Tidy”: 1 step was left out", "Loaded “Tidy”: 2 steps were left out"), ]; -/// The link, font and export status messages name a count of 1 in the singular and other counts in +/// The link, font, export and action set status messages name a count of 1 in the singular and other counts in /// the plural, and every complete catalog translates both, in the form its plural rule picks. #[test] fn counted_status_messages_take_the_plural_form() { @@ -1841,6 +1887,42 @@ fn width(p: &Value, cmd: &str, key: &str) -> Result { assert_eq!(c.message("`width` must be a number").map(|m| m.1), Some(vec![("key", "width")])); } +/// The warnings of an Image Trace with keepColors, which the status bar shows and the scan above +/// doesn't read, are translated in each language of [`COMPLETE_MESSAGES`], with the colors kept. +#[test] +fn the_keep_colors_trace_warnings_are_translated() { + use serde_json::json; + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + // A white field with a black square in it. + let img = image::RgbaImage::from_fn(40, 40, |x, y| { + image::Rgba(if (10..30).contains(&x) && (10..30).contains(&y) { [0, 0, 0, 255] } else { [255, 255, 255, 255] }) + }); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let data = vectorcraft_format::base64_encode(&png); + let mut warnings = vec![]; + for (params, shown) in [ + (json!({"mode": "color", "palette": "limited", "colors": 2, "keepColors": ["#ff00ff", "#00ffff"]}), "#ff00ff, #00ffff"), + (json!({"mode": "color", "palette": "limited", "colors": 2, "keepColors": ["#000000", "#00ffff"]}), "#00ffff"), + (json!({"mode": "color", "palette": "limited", "colors": 2, "keepColors": ["#000000"], "ignoreWhite": true, "ignoreColor": "#000000"}), ""), + (json!({"mode": "logo", "keepColors": ["#000000"]}), ""), + ] { + s.execute("file.place", &json!({"name": "square.png", "dataBase64": data, "link": false})).unwrap(); + let r = s.execute("imageTrace.makeAndExpand", &json!({ "params": params })).unwrap(); + let w = r["warnings"].as_array().and_then(|w| w.first()).and_then(|w| w.as_str()).unwrap_or_else(|| panic!("{params}: {r}")); + warnings.push((w.to_string(), shown)); + } + // Listed colors that were traced and produced no paths (no square image gives that). + warnings.push(("Nothing was traced: the colors that match keepColors produce no paths.".to_string(), "")); + for (warning, shown) in &warnings { + for code in COMPLETE_MESSAGES { + let tr = message(Lang::from_code(code).unwrap(), warning); + assert!(tr != *warning && tr.contains(shown) && tr.contains("keepColors"), "{code}: {tr}"); + } + } +} + /// The parameter errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add /// Spine Point and Delete Spine Point, which Direct Selection, the Pen and the Anchor Point tool /// send) say what is wrong. @@ -1961,6 +2043,28 @@ fn saved_tracing_preset_errors_are_translated() { } } +/// The notes on names in a `.ai`'s editing data that aren't UTF-8 are translated in every language +/// that translates every message, keeping the name, the encoding and the param. +#[test] +fn legacy_name_notes_are_translated() { + let notes = [ + "the name `·¹À̾î 1` isn't UTF-8 and was read as windows-1252: if it looks wrong, open the file with another legacyNameEncoding", + "the name `레\u{fffd} 1` isn't UTF-8, and parts of it aren't valid windows-949 either: open the file with another legacyNameEncoding", + "more names that aren't UTF-8 were read as macintosh (7)", + ]; + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + for note in notes { + let shown = message(lang, note); + assert_ne!(shown, note, "{code}"); + assert!(shown.contains("UTF-8"), "{code}: {shown}"); + } + let shown = message(lang, notes[0]); + assert!(shown.contains("`·¹À̾î 1`") && shown.contains("windows-1252") && shown.contains("legacyNameEncoding"), "{code}: {shown}"); + assert!(message(lang, notes[2]).contains("macintosh") && message(lang, notes[2]).contains('7'), "{code}"); + } +} + /// Export Data Sets' refusals of more data sets or variables than Import Data Sets reads are /// translated in every language that translates every message. #[test] @@ -2051,9 +2155,9 @@ fn dataset_import_cell_errors_are_translated() { /// The errors of the parameter checks that many commands share (a number given as text, a missing /// number or index, a fraction for a whole number, `ids` that aren't a list of object ids, an -/// object id that is negative or names no object, a date that isn't Unix seconds) fill their -/// templates, and each language shows the value in the message, such as a quoted `"60 mm"`, as -/// given: in its row's translation, or in English where it has none. +/// `ids` entry or an `id` that is negative, an object id that names no object, a date that isn't +/// Unix seconds) fill their templates, and each language shows the value in the message, such as a +/// quoted `"60 mm"`, as given: in its row's translation, or in English where it has none. #[test] fn shared_param_check_errors_show_their_values_as_given() { use serde_json::json; @@ -2064,7 +2168,7 @@ fn shared_param_check_errors_show_their_values_as_given() { s.execute("select.all", &json!({})).unwrap(); // A command, its params, the English template of its error and the template's values. type Case = (&'static str, serde_json::Value, &'static str, &'static [(&'static str, &'static str)]); - let cases: [Case; 10] = [ + let cases: [Case; 11] = [ ("object.move", json!({"dx": "60 mm"}), "`{key}` must be a number, not {v}", &[("key", "dx"), ("v", r#""60 mm""#)]), ("object.rotate", json!({"angle": true}), "`{key}` must be a number, not {v}", &[("key", "angle"), ("v", "true")]), ("object.setBounds", json!({"width": [1, 2]}), "`{key}` must be a number, not {v}", &[("key", "width"), ("v", "[1,2]")]), @@ -2072,7 +2176,13 @@ fn shared_param_check_errors_show_their_values_as_given() { ("appearance.setItem", json!({}), "missing integer `{key}`", &[("key", "index")]), ("object.transformEach", json!({"reference": 2.5}), "`{key}` must be a whole number", &[("key", "reference")]), ("edit.clear", json!({"ids": "all"}), "{key} must be an array of non-negative integer object ids", &[("key", "ids")]), - ("edit.clear", json!({"ids": [-1]}), "invalid object id {value}: expected a non-negative integer", &[("value", "-1")]), + ( + "edit.clear", + json!({"ids": [-1]}), + "`{key}` entry {index} is {value}, not a non-negative integer object id", + &[("key", "ids"), ("index", "0"), ("value", "-1")], + ), + ("paint.invert", json!({"id": -1}), "invalid object id {value}: expected a non-negative integer", &[("value", "-1")]), ("edit.clear", json!({"ids": [99999]}), "no such object id {value}", &[("value", "99999")]), ("file.new", json!({"created": "yesterday"}), "{key} must be Unix seconds (an integer) or null", &[("key", "created")]), ]; @@ -2196,6 +2306,151 @@ fn translated_messages_keep_the_double_quoted_values_of_their_english() { assert!(missing.is_empty(), "{} rows drop a double-quoted value:\n{}", missing.len(), missing.join("\n")); } +/// Whether `text` holds `word` with no ASCII letter, digit or underscore right before or after it, +/// so `true` counts in `true이거나` but not in `untrue`. +fn holds_ascii_word(text: &str, word: &str) -> bool { + let joins = |c: char| c.is_ascii_alphanumeric() || c == '_'; + text.match_indices(word).any(|(at, _)| { + let before = text[..at].chars().next_back().is_none_or(|c| !joins(c)); + let after = text[at + word.len()..].chars().next().is_none_or(|c| !joins(c)); + before && after + }) +} + +/// The literal values `true`, `false` and `null` that `s` names outside its backticked and +/// double-quoted spans. +fn bare_literals(s: &str) -> Vec<&'static str> { + let mut outside = String::new(); + let mut quoted = false; + for part in s.split('`').step_by(2) { + for (i, piece) in part.split('"').enumerate() { + if i > 0 { + quoted = !quoted; + } + if !quoted { + outside.push_str(piece); + outside.push(' '); + } + } + } + ["true", "false", "null"].into_iter().filter(|lit| holds_ascii_word(&outside, lit)).collect() +} + +#[test] +fn bare_literals_finds_true_false_and_null_outside_backticks_and_quotes() { + assert_eq!(bare_literals("clip must be true or false"), ["true", "false"]); + assert_eq!(bare_literals("`leading` must be from 0.1 to 5000, or null for Auto"), ["null"]); + assert_eq!(bare_literals(r#"`true` is "false", not untrue"#), Vec::<&str>::new()); + assert!(holds_ascii_word("true이거나", "true")); + assert!(!holds_ascii_word("isTrue or falsey", "false")); +} + +/// A translated status or error message keeps each `true`, `false` and `null` its English text +/// names without backticks, spelled as the English spells it, since that is the value a command +/// reads. +#[test] +fn translated_messages_keep_the_bare_literals_of_their_english() { + let mut missing = Vec::new(); + for l in &LANGUAGES { + let (entries, _) = parse_entries(l.source); + for (ctx, src, tr) in entries.iter().filter(|(ctx, _, _)| ctx == "@msg" || ctx == "@msgplural") { + let plural = ctx == "@msgplural"; + let english = if plural { src.rsplit('|').next().unwrap_or(src) } else { src.as_str() }; + let forms: Vec<&str> = if plural { tr.split('|').collect() } else { vec![tr.as_str()] }; + for form in forms { + for lit in bare_literals(english) { + if !holds_ascii_word(form, lit) { + missing.push(format!("{}: {lit} in {src:?} -> {form:?}", l.code)); + } + } + } + } + } + assert!(missing.is_empty(), "{} rows drop a bare true, false or null:\n{}", missing.len(), missing.join("\n")); +} + +/// Messages that name parameter keys and values without backticks, each with the names a +/// translation keeps as written in the English. +const NAMED_KEYS: &[(&str, &[&str])] = &[ + ("a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard", &["useArtboards"]), + ("a name names one asset: give multiple: false, or one object", &["multiple"]), + ("a theme is already called {name} (replace: \"{name}\" overwrites it)", &["replace"]), + ("action is required", &["action"]), + ("anchor must name a registered panel", &["anchor"]), + ("antiAlias `{id}`: none, art or type", &["antiAlias", "none", "art", "type"]), + ("axis must be \"vertical\", \"horizontal\" or an angle in degrees, not `{v}`", &["axis"]), + ("balance must be between 0 and 100", &["balance"]), + ("before must name a panel", &["before"]), + ("brightness must be dark|mediumDark|mediumLight|light", &["brightness", "dark", "mediumDark", "mediumLight", "light"]), + ("clip must be true or false", &["clip"]), + ("collapsed must be true or false", &["collapsed"]), + ("column must be true or a number from 1", &["column"]), + ("column: true floats the whole column and takes no onto, above or below", &["column", "onto", "above", "below"]), + ("control must be fillStroke, coloring, drawMode or screenMode", &["control", "fillStroke", "coloring", "drawMode", "screenMode"]), + ("decimals must be {_1}–{_2} (got {_3})", &["decimals"]), + ("detect must be true or false", &["detect"]), + ("give origin: [x, y], or reset: true", &["origin", "reset"]), + ("global must be true or false", &["global"]), + ("group must be true or false", &["group"]), + ("icon must be true or false", &["icon"]), + ("included must be true or false", &["included"]), + ("index must be a number or first, previous, next or last", &["index", "first", "previous", "next", "last"]), + ("index must be a number or first, previous, next or last, not `{other}`", &["index", "first", "previous", "next", "last"]), + ("index must be ≥ 0", &["index"]), + ("kerning must be a number, \"auto\", or \"optical\"", &["kerning"]), + ("lang must be one of {_1}", &["lang"]), + ("layers must be true or false", &["layers"]), + ("member is for order front, forward, backward or back", &["member", "order", "front", "forward", "backward", "back"]), + ("mode is set, add or toggle, not `{other}`", &["mode", "set", "add", "toggle"]), + ("names is empty", &["names"]), + ("on must be true or false", &["on"]), + ("onto must name a floating panel", &["onto"]), + ("others must be true or false", &["others"]), + ("page must be a whole number from 1", &["page"]), + ("panel is not in a dock group", &["panel"]), + ("panel must name a registered panel", &["panel"]), + ("paths must be strings", &["paths"]), + ("percent must be above 0 and at most 6400, not {p}", &["percent"]), + ("plane must be left, right or ground", &["plane", "left", "right", "ground"]), + ("precision must be from 0.001 to 10 pt", &["precision"]), + ("rect is [x, y, width, height] in points", &["rect"]), + ("scale must be a number from {_1} to {_2}, not {_3}", &["scale"]), + ("scale must be a number from {_1} to {_2}, not {s}", &["scale"]), + ("show must be true or false", &["show"]), + ("span: \"selection\" places a linear or radial gradient, not a freeform one", &["span"]), + ("thickness must be from 0.01 to 5000 pt", &["thickness"]), + ("title can't be empty", &["title"]), + ("view is required", &["view"]), + ("width must be positive", &["width"]), + ("zone must be a string", &["zone"]), + ("zone must be tab, center, left, right, top or bottom", &["zone", "tab", "center", "left", "right", "top", "bottom"]), + ("`dither` applies to gradient fills (stroke: false)", &["stroke"]), + ("`strokeMode` applies to strokes (stroke: true)", &["stroke"]), +]; + +/// Every catalog's translation of a [`NAMED_KEYS`] message keeps each name the table lists, as +/// a whole word, and every message in the table is still in a catalog. +#[test] +fn translated_messages_keep_the_keys_and_values_their_english_names() { + let mut missing = Vec::new(); + let mut seen = vec![false; NAMED_KEYS.len()]; + for l in &LANGUAGES { + let (entries, _) = parse_entries(l.source); + for (_, src, tr) in entries.iter().filter(|(ctx, _, _)| ctx == "@msg") { + let Some(at) = NAMED_KEYS.iter().position(|(english, _)| english == src) else { continue }; + seen[at] = true; + for name in NAMED_KEYS[at].1 { + if !holds_ascii_word(tr, name) { + missing.push(format!("{}: {name} in {src:?} -> {tr:?}", l.code)); + } + } + } + } + let stale: Vec<&str> = NAMED_KEYS.iter().zip(&seen).filter(|(_, seen)| !**seen).map(|((english, _), _)| *english).collect(); + assert!(stale.is_empty(), "no catalog translates {stale:?}"); + assert!(missing.is_empty(), "{} rows drop a key or value their English names:\n{}", missing.len(), missing.join("\n")); +} + /// Languages whose catalogs translate status and error messages; [`untranslated_report`] lists /// the messages each one lacks. const COMPLETE_MESSAGES: &[&str] = &[ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 9385b30cc..14238fd47 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -183,6 +183,10 @@ Anti-Alias Kenar Yumuşatma Anti-alias Rasters Kenar Yumuşatma Rasterleri Anti-aliased Artwork Kenar Yumuşatılmış Sanat Çalışması + Anti-aliasing Method Kenar Yumuşatma Yöntemi +antiAlias Sharp Keskin + Crisp Net + Strong Güçlü Anti-aliasing: Kenar yumuşatma: Any Kind Her Türlü Font Technology Yazı Tipi Teknolojisi @@ -351,6 +355,13 @@ font-technology Variable Değişken Apply Bullets Madde İşaretlerini Uygula Numbered List Numaralı Liste Apply Numbers Numaralandırmayı Uygula + Bullets and Numbering… Madde İşaretleri ve Numaralandırma… + Numbers Numaralar + Level: Düzey: + Bullet Character: Madde İşareti Karakteri: + Number Style: Numara Stili: + Left Indent: Sol Girinti: + First Line Indent: İlk Satır Girintisi: Convert to Text Metne Dönüştür Filled Dot Dolu Nokta Dash Tire @@ -536,6 +547,11 @@ font-technology Variable Değişken Color Table ({n} colors) Renk Tablosu ({n} renkleri) Color Theme Renk Teması Color Themes Renk Temaları + Color Wheel Renk Tekerleği + Hue ring Renk tonu halkası + Saturation and brightness Doygunluk ve parlaklık + Color field Renk seçim alanı + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Renk Türü: Color: Renk: Colorize Renklendir @@ -1057,6 +1073,14 @@ font-technology Variable Değişken Export As SVG… SVG Olarak Dışa Aktar… Export Asset Varlığı Dışa Aktar Export Assets Varlıkları Dışa Aktar + Export 3D Object 3D Nesneyi Dışa Aktar + 3D Export Format 3D Dışa Aktarma Biçimi + Export 3D Assets 3D Varlıkları Dışa Aktar +@msg {what} can't be exported in 3D: {e} {what} 3D olarak dışa aktarılamıyor: {e} +@msg asset {id} isn't a 3D asset {id} varlığı bir 3D varlık değil +@msg asset {id} is a 3D asset: assets.export3D writes it {id} varlığı bir 3D varlık: onu assets.export3D yazar +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) henüz 3D varlık yok: önce 3D efekti olan bir nesne toplayın (assets.add3D) +@msg select an object with a 3D effect (or give its ids) 3D efekti olan bir nesne seçin (veya kimliklerini verin) Export As… Farklı Dışa Aktar… Export CSS CSS'yi dışa aktar Export CSS… CSS'yi dışa aktar… @@ -1088,6 +1112,17 @@ font-technology Variable Değişken Exporting {} {} dışa aktarılıyor Export… İhracat… Extrude & Bevel… Ekstrüzyon ve Eğim… + 3D (Classic) 3D (Klasik) + Extrude & Bevel (Classic)… Ekstrüzyon ve Eğim (Klasik)… + Surface Yüzey + Wireframe Tel Kafes + No Shading Gölgeleme Yok + Plastic Plastik + Highlight Intensity Parlama Yoğunluğu + Highlight Size Parlama Boyutu + Blend Steps Karışım Adımları + Shading Color Gölgeleme Rengi + Draw Hidden Faces Gizli Yüzleri Çiz Eyedropper Damlalık Sample Color from Screen Ekrandan Örnek Renk This system has no screen color sampler Bu sistemin ekran rengi örnekleyicisi yok @@ -1127,6 +1162,7 @@ font-technology Variable Değişken Merge only with selection Yalnızca seçimle birleştir Fill Color Dolgu Rengi Fill New Type Objects With Placeholder Text Yeni Yazım Nesnelerini Yer Tutucu Metinle Doldurun + Start Lists From Typed Markers Yazılan İşaretlerden Liste Başlat Fill Rule Doldurma Kuralı Fill with Placeholder Text Yer Tutucu Metinle Doldur Fill: Doldurun: @@ -1137,6 +1173,7 @@ font-technology Variable Değişken Find Font… Yazı Tipi Bul… Find Next Sonrakini Bul Find Objects Nesneleri Bul + Compare with Image Görüntüyle Karşılaştır First Page İlk Sayfa First-line Left Indent Birinci Satırda Sol Girinti Fisheye… Balıkgözü… @@ -1551,6 +1588,7 @@ font-technology Variable Değişken Report copied Rapor kopyalandı Line {line} {line} Hattı Line {line}: {name}: {status} Hat {line}: {name}: {status} + Read Action Set File Eylem Seti Dosyasını Oku Read Shortcut Set File Kısayol Seti Dosyasını Oku Import Matches Eşleşmeleri İçe Aktar Remember Import Matches İçe Aktarılan Eşleşmeleri Hatırla @@ -1793,6 +1831,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 @@ -2568,6 +2610,7 @@ digits Malayalam Malayalam Reset Transformation Dönüşümü Sıfırla Replace With Font Yazı Tipiyle Değiştir Replace With Font From Değiştirilecek Yazı Tipi Kaynağı + Find In Arama yeri System Sistem Replaces the saved preset of that name. Bu adın kayıtlı ön ayarını değiştirir. Replace… Değiştir… @@ -2888,6 +2931,11 @@ digits Malayalam Malayalam Selected Swatches Seçilen Renk Örnekleri Selected Swatches Only Yalnızca Seçilen Renk Örnekleri Selection Seçim +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Dikdörtgen seçim + Touching Dokunulanlar + Fully Enclosed Tamamen içeride + Drag Direction Sürükleme yönü Selection & Anchor Display Seçim ve Bağlantı Ekranı Selection Only Yalnızca Seçim Selection Tool Seçim Aracı @@ -3022,6 +3070,7 @@ digits Malayalam Malayalam Simulate Colored Paper Renkli Kağıdı Simüle Et Simulate Paper Kağıdı Simüle Et Double Column Çift Sütun + Keep Tools Beside the Canvas Araçları Tuvalin Yanında Tut Single Left Quotation Mark Tek Sol Tırnak İşareti Single Quotes: Tek Alıntılar: Single Right Quotation Mark Tek Sağ Tırnak İşareti @@ -3258,6 +3307,15 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Tate-chu-yoko Ayarları… Tate-chu-yoko Settings Tate-chu-yoko Ayarları + Composite Fonts… Bileşik Fontlar… + Composite Fonts Bileşik Fontlar + Export Composite Fonts Bileşik Fontları Dışa Aktar + Import Composite Fonts Bileşik Fontları İçe Aktar + Delete Font Fontu Sil + Kanji Kanji + Kana Kana + Numerals Rakamlar + Scale at Center Merkezden Ölçekle Up/Down: Yukarı/Aşağı: Left/Right: Sol/Sağ: Temperature Sıcaklık @@ -3601,6 +3659,7 @@ digits Malayalam Malayalam Zoom to Selection Seçimi Yakınlaştır Zoom Tool Yakınlaştırma Aracı Zoom with Mouse Wheel Fare Tekerleği ile Yakınlaştırma + Scroll Sideways with Cmd/Ctrl + Wheel Cmd/Ctrl + Tekerlek ile Yatay Kaydırma Scrub Numeric Fields by Dragging Sayısal Alanları Sürükleyerek Fırçalayın @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {folder} içinde {n} dosya {note}. Paket klasörü gösterilsin mi? @plural {n} linked file not found|{n} linked files not found {n} bağlantılı dosya bulunamadı @@ -3765,7 +3824,7 @@ axis Both Her İkisi Dock to Bottom Alta yerleştir Dock Control Bar Kontrol çubuğunu yerleştir Drag to float the toolbar Araç çubuğunu ayırmak için sürükleyin - Drag to the window's left edge to dock Sabitlemek için pencerenin sol kenarına sürükleyin + Drag to the window's left or right edge to dock Sabitlemek için pencerenin sol veya sağ kenarına sürükleyin Float Tool Group Araç Grubunu Yüzer Yap @msg no library `{id}` `{id}` kütüphanesi yok @msg select some type first önce bir tür seçin @@ -3866,6 +3925,7 @@ axis Both Her İkisi @msg Targa files are RGB: colorModel rgb Targa dosyaları RGB'dir: colorModel rgb @msg Text Wrap: select a wrap object Metin Sarma: bir sarma nesnesi seçin @msg This shortcut file is in a form VectorCraft can't read. Bu kısayol dosyası VectorCraft'ın okuyamayacağı bir biçimdedir. +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` "none", "sharp", "crisp" veya "strong" olmalıdır @msg `aspect` must be a number (%) `aspect` bir sayı olmalıdır (%) @msg `at` must be [x, y] `at` [x, y] olmalıdır @msg `color` must be a colour `color` bir renk olmalıdır @@ -3947,7 +4007,7 @@ axis Both Her İkisi @msg already editing a pattern zaten bir desen düzenliyor @msg an object can't be read bir nesne okunamıyor @msg an object in perspective would leave the edited grid perspektifteki bir nesne düzenlenen ızgaradan ayrılır -@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: yok, resim veya tür +@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art veya type @msg artboard {0} does not exist {0} çalışma yüzeyi mevcut değil @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard bu ölçekte çizim {w} × {h} nk, kanvasın sığabileceğinden daha fazladır: daha küçük bir ölçek seçin veya çalışma yüzeyine sığdırın @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis "vertical", "horizontal" veya derece cinsinden bir açı olmalıdır, `{v}` değil @@ -4017,6 +4077,11 @@ axis Both Her İkisi @msg invalid image data for `{0}` `{0}` için geçersiz resim verileri @msg invalid parameters for `{cmd}`: {msg} `{cmd}` için geçersiz parametreler: {msg} @msg is not an image VectorCraft reads VectorCraft'ın okuduğu bir resim değil +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: PNG, JPEG, WebP, TIFF veya BMP resmi değil +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ resim {iw} × {ih} piksel, çalışma yüzeyi bu genişlikte {rw} × {rh} piksel: oranları farklı +@msg `threshold` must be a number from 0 to 1 `threshold` 0 ile 1 arasında bir sayı olmalıdır +@msg background {v}: a color such as "#808080" background {v}: "#808080" gibi bir renk +@msg comparing with an image file isn't available in the browser bir resim dosyasıyla karşılaştırma tarayıcıda kullanılamaz @msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know VectorCraft'ın PostScript okuyucusunun bilmediği `{at}`yi kullanıyor @msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know, in {procs} {procs}'de VectorCraft'ın PostScript okuyucusunun bilmediği `{at}`yi kullanıyor @msg its Windows metafile preview was opened instead, which may simplify the art bunun yerine Windows meta dosyası önizlemesi açıldı, bu da işi basitleştirebilir @@ -4110,6 +4175,8 @@ axis Both Her İkisi @msg no such action set öyle bir eylem seti yok @msg the action sets changed; nothing was deleted eylem setleri değişti; hiçbir şey silinmedi @msg no action set to save kaydedilecek eylem seti yok +@msg the action set file can't be read in full eylem seti dosyası tamamen okunamıyor +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” yüklendi: {n} adım atlandı @msg not an action set file bir eylem seti dosyası değil @msg the action set file is larger than 8 MiB eylem seti dosyası 8 MiB'den büyük @msg the action set file holds more than 1,000 actions eylem seti dosyası 1.000'den fazla eylem içeriyor @@ -4220,6 +4287,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 @@ -4442,11 +4516,15 @@ axis Both Her İkisi @msg its objects are nested too deeply nesneleri çok derin bir şekilde iç içe geçmiş @msg its section ending with `%{end}` doesn't end `%{end}` ile biten bölümü bitmiyor @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) VectorCraft'ın henüz okuyamadığı fırçalar Fırçalar panelinin dışında bırakıldı ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet çeşitli dolgulara veya konturlara, efektlere veya fırçalara sahip nesneler çizilmiş görünümleri olarak geldi ({_1}): görünümleri henüz düzenlenemez +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet çeşitli dolgulara veya konturlara, değişken genişlikli konturlara, efektlere veya fırçalara sahip nesneler çizilmiş görünümleri olarak geldi ({_1}): görünümleri henüz düzenlenemez +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding `{name}` adı UTF-8 değil ve {label} olarak okundu: yanlış görünüyorsa dosyayı başka bir legacyNameEncoding ile açın +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding `{name}` adı UTF-8 değil ve bazı bölümleri {label} olarak da geçerli değil: dosyayı başka bir legacyNameEncoding ile açın +@msg more names that aren't UTF-8 were read as {label} ({count}) UTF-8 olmayan diğer adlar {label} olarak okundu ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it “{name}” degradesi dosyada tanımlı değil: onun yerine düz bir renk geliyor @msg an image VectorCraft can't read was left out VectorCraft'ın okuyamadığı bir resim dışarıda bırakıldı @msg graphs come in as plain art, without their data or settings grafikler, verileri ve ayarları olmadan düz çizim olarak içe aktarılır @msg an image whose data is cut short was left out verileri kısa kesilen bir resim dışarıda bırakıldı +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers katmanları korumak için {_1} × {_2} piksellik gömülü bir resim {_3} × {_4} piksele küçültüldü @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel bağlantılı resimler, dosyaları bulunana kadar dosyaya kaydedilen düşük çözünürlüklü önizlemeyi gösterir: bunları Bağlantılar panelinde yeniden bağlayın @msg linked type areas past VectorCraft's limits were left out VectorCraft sınırlarını aşan bağlantılı metin alanları dışarıda bırakıldı @msg type that changes writing direction inside a text object opens in the direction of its first character bir metin nesnesi içinde yazı yönünü değiştiren metin, ilk karakterinin yönünde açılır @@ -4547,12 +4625,19 @@ axis Both Her İkisi @msg the Pixar file's image data is damaged Pixar dosyasının görüntü verileri bozuk @msg the JPEG 2000 file can't be read JPEG 2000 dosyası okunamıyor @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 dosyasında desteklenmeyen bir renk alanı veya JPX içeriği var +@msg the HEIC or HEIF file can't be read HEIC veya HEIF dosyası okunamıyor +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC veya HEIF dosyası okunamayan türde bir görüntü içeriyor +@msg HEIC and HEIF images can't be opened in this build HEIC ve HEIF görüntüleri bu derlemede açılamıyor @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD görüntüsü okunamayacak kadar büyük ({_1} × {_2} piksel) @msg zipped 32-bit PSD image data with prediction can't be read Tahmin içeren sıkıştırılmış 32 bit PSD görüntü verileri okunamıyor @msg {depth}-bit PSD files in this colour mode can't be read Bu renk modunda {depth}-bit PSD dosyaları okunamaz @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 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 De-Interlace… Taramasızlaştırma… De-Interlace Taramasızlaştırma @@ -4609,6 +4694,9 @@ axis Both Her İkisi effect Soft Yumuşak Edge Kenar System Title Bar Sistem Başlık Çubuğu + VectorCraft Title Bar on Every Desktop Her Masaüstünde VectorCraft Başlık Çubuğu + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. VECTORCRAFT_TITLEBAR ortam değişkeni ayarlı olduğu sürece başlık çubuğunu o seçer. + System Title Bar takes precedence over this option while it is on. Sistem Başlık Çubuğu açık olduğu sürece bu seçeneğe göre önceliklidir. Value Axis Değer Ekseni On Left Side Sol Tarafta On Right Side Sağ Tarafta @@ -5499,6 +5587,7 @@ theme Appearance Görünüm @msg missing integer `{key}` `{key}` tam sayısı eksik @msg {key} must be an array of non-negative integer object ids {key}, negatif olmayan tam sayı nesne kimlikleri dizisi olmalıdır @msg invalid object id {value}: expected a non-negative integer geçersiz nesne kimliği {value}: negatif olmayan bir tam sayı bekleniyordu +@msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` içindeki {index} dizinli öğe {value}, negatif olmayan tam sayı bir nesne kimliği değil @msg no such object id {value} böyle bir nesne kimliği yok: {value} @msg id {value} is a layer, not an object {value} kimliği bir katmana ait, bir nesneye değil @msg id {value} is a layer, not an object; move layers with layer.move {value} kimliği bir katmana ait, bir nesneye değil; katmanları layer.move ile taşıyın @@ -5706,7 +5795,15 @@ theme Appearance Görünüm @msg library panel `{given}` isn't missing its library `{given}` kitaplık panelinin kitaplığı eksik değil @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Görüntü {_1} × {_2} piksel, bir kenarı {LARGE_TRACE_SIDE} değerinden büyük: izlenir, ancak daha yavaş. Daha düşük doğruluklu bir ön ayar veya görüntünün daha küçük bir kopyası daha hızlı izlenir. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Görüntü {_3} × {_4} boyutundan küçültülerek {_1} × {_2} pikselde izlendi (en fazla {_5} megapiksel). +@msg byColor must be true or false byColor true veya false olmalıdır +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor, Otomatik Gruplama gruplarını bölemez: Otomatik Gruplama olmadan izleyin +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor, canlı Pathfinder efekti olan bir izlemeyi yeniden gruplayamaz: byColor olmadan genişletin Gap Length: Boşluk Uzunluğu: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Düz Logo modu keepColors kullanmaz: logoColors içinde adı verilen renkleri veya bulduğu renkleri izler. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Hiçbir şey izlenmedi: izlemenin bulduğu renklerin hiçbiri keepColors ({_1}) ile eşleşmiyor. +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Hiçbir şey izlenmedi: Rengi Yoksay, keepColors içinde listelenen renklerin tüm alanlarını dışarıda bırakıyor. +@msg Nothing was traced: the colors that match keepColors produce no paths. Hiçbir şey izlenmedi: keepColors ile eşleşen renkler hiçbir yol oluşturmuyor. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: izlemenin bulduğu renklerin hiçbiri {c} ile eşleşmiyor, bu yüzden bu renkten hiçbir şey izlenmiyor. @msg `colorGroup` needs `library: "document"` `colorGroup` için `library: "document"` gerekir @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` için `palette: "documentLibrary"` gerekir @msg `colorGroup` needs `mode: "color"` `colorGroup` için `mode: "color"` gerekir @@ -5778,11 +5875,14 @@ axis Y Axis Y ekseni @msg Running “{name}”… “{name}” çalıştırılıyor… @msg a live effect runs while the document is drawn, so it can't be `heavy` canlı efekt belge çizilirken çalışır, bu yüzden `heavy` olamaz @msg an exporter always gets an export's budget: leave `budget` out dışa aktarma eklentisi her zaman bir dışa aktarmanın bütçesini alır: `budget` anahtarını çıkarın -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” büyük bir hesaplama bütçesi istiyor (her çalıştırma en fazla {minutes} dakika ve olağan komutların {times} katı): kullanıcı onayladıktan sonra `allowHeavy: true` ile yükleyin +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” büyük bir hesaplama bütçesi istiyor (her çalıştırma en fazla {minutes} dakika ve olağan komutların {times} katı): masaüstü uygulamasında bir kişinin onaylaması gerekir veya --allow-plugin-grants ile başlatılan bir oturumda `allowHeavy: true` geçirin # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Bu izinleri isteyen “{name}” yüklensin mi? ID: {id} Kimlik: {id} Version: {version} Sürüm: {version} + Install “{name}” in place of the plug-in with the same ID? Aynı kimliğe sahip eklentinin yerine “{name}” yüklensin mi? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Bu dosya, daha önce bu kimlikle yüklenen eklenti değil. O kimlik için saklanan ayarları ve önbelleği alır. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}”, {_1} kimlikli eklentiden farklı bir modül ve onun için saklanan ayarları ve önbelleği alacak: kullanıcı onayladıktan sonra `replace: true` ile yükleyin File SHA-256: {hash} Dosya SHA-256: {hash} Read the objects it is given Kendisine verilen nesneleri okuma Change, add and delete objects in the document Belgedeki nesneleri değiştirme, ekleme ve silme @@ -5790,21 +5890,43 @@ 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 Show its progress, see Cancel and write log lines İlerlemesini gösterme, iptali algılama ve günlük satırları yazma -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `progress` izni için eklenti API düzeyi {PROGRESS_API} gerekir: `api` {PROGRESS_API} verin + Keep settings of its own Kendi ayarlarını saklama + Keep a cache of its own, which you can clear Temizleyebileceğiniz kendi önbelleğini saklama + Clear Plug-in Cache Eklenti Önbelleğini Temizle +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `{permission}` izni için eklenti API düzeyi {PROGRESS_API} gerekir: `api` {PROGRESS_API} verin @msg the module imports `env.{name}` with another type than the host's: it is {signature} modül `env.{name}` öğesini ana bilgisayarınkinden farklı bir türle içe aktarıyor: ana bilgisayarın türü {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modül `env.{name}` öğesini içe aktarıyor; bir eklenti bunu yalnızca eklenti API düzeyi {PROGRESS_API} (`api` {PROGRESS_API}) ile ve `progress` izniyle kullanabilir -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modül, ana bilgisayarın sunmadığı `{from}.{name}` öğesini içe aktarıyor: bir eklenti yalnızca `env.vc_progress`, `env.vc_cancelled` ve `env.vc_log` öğelerini (eklenti API düzeyi 2) içe aktarabilir; WASI, dosya veya ağ kullanamaz +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` modül `env.{name}` öğesini içe aktarıyor; bir eklenti bunu yalnızca eklenti API düzeyi {PROGRESS_API} (`api` {PROGRESS_API}) ile ve `{permission}` izniyle kullanabilir +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network modül, ana bilgisayarın sunmadığı `{from}.{name}` öğesini içe aktarıyor: bir eklenti yalnızca docs/plugins.md dosyasında listelenen `env.vc_*` işlevlerini (eklenti API düzeyi 2) içe aktarabilir; WASI, dosya veya ağ kullanamaz @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` may only be true `paintOutput` yalnızca true olabilir +@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 @msg at most {MAX_PERMISSIONS} `permissions` en fazla {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` `{shown}` izni `{_1}` türündeki bir eklentiye uygulanmaz @msg permission `{shown}` is listed twice `{shown}` izni iki kez listelenmiş +@msg only a filter declares `inputs` `inputs` yalnızca bir filtrede bildirilebilir +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` bilinmeyen bir nesne türü belirtiyor: `{shown}`; type, image veya symbolInstance kullanın +@msg `inputs` lists `{shown}` twice `inputs` `{shown}` değerini iki kez listeliyor +@msg `inputs` needs `api` 2 or later `inputs` için `api` 2 veya üstü gerekir +@msg `inputs` needs the permission `doc.read` `inputs` için `doc.read` izni gerekir +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) bellek bütçesi (nesne {_1} için {size} bayt girdi gerekiyor, {left} kaldı) +@msg object budget (more than {MAX_OBJECTS} read-only objects) nesne bütçesi ({MAX_OBJECTS} adetten fazla salt okunur nesne) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` izni sonraki bir eklenti API’si için ayrılmıştır; bu ana bilgisayar bu izni vermez @msg unknown permission `{shown}` bilinmeyen izin `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” {asked} izinlerini istiyor: kullanıcı onayladıktan sonra bunları listeleyen bir `grant` ile yükleyin +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” {asked} izinlerini istiyor: masaüstü uygulamasında bir kişinin onaylaması gerekir veya --allow-plugin-grants ile başlatılan bir oturumda `grant` geçirin +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` ve `allowHeavy` yalnızca --allow-plugin-grants ile başlatılan bir oturumda etkili olur: eklentiyi masaüstü uygulamasında onaylayın +@msg Only the person at the keyboard can approve installing a plug-in Bir eklentinin yüklenmesini yalnızca klavye başındaki kişi onaylayabilir @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” öğesine çıktısının ihtiyaç duyduğu `{p}` izni verilmedi: hiçbir şey değiştirilmedi +@msg a heavy filter runs in the background, so it can't declare `commands` ağır bir filtre arka planda çalışır, bu yüzden `commands` bildiremez +@msg at most {MAX_COMMANDS} `commands` en fazla {MAX_COMMANDS} `commands` +@msg command {name} is declared twice {name} komutu iki kez bildirilmiş +@msg command {name}: `label` must be 1-64 printable characters {name} komutu: `label` 1-64 yazdırılabilir karakter olmalıdır +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter {name} komutu: a-z 0-9 _ karakterlerinden oluşan ve harfle başlayan 1-{MAX_COMMAND_ID} karakterlik bir kimlik kullanın +@msg command {name}: {e} {name} komutu: {e} +@msg only a filter or a live effect declares `commands` `commands` yalnızca bir filtre veya canlı efekt tarafından bildirilir +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modül `hash` içinde verilen SHA-256 değerine sahip değil: hiçbir şey yüklenmedi # Actions panel: action sets (#3712). New Set… Yeni Set… @@ -6222,6 +6344,8 @@ breakText Glyphs Glifler File + Data Set Name Dosya + veri kümesi adı Data Set Name Veri kümesi adı @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` değeri `fileNumber`, `fileDataSetName` veya `dataSetName` olmalıdır + Each data set saves over the same file, which keeps only the last data set. Her veri kümesi aynı dosyanın üzerine kaydedilir ve dosyada yalnızca son veri kümesi kalır. +@msg save the document with Save As before Save and Close Kaydet ve Kapat'tan önce belgeyi Farklı Kaydet ile kaydedin @msg no free data set file name in destination folder Hedef klasörde veri kümesi için boş bir dosya adı yok # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6341,9 +6465,20 @@ breakText Glyphs Glifler Reset Symbol Instance Sembol Örneğini Sıfırla @msg type must be static or dynamic, not {v} type static veya dynamic olmalıdır, {v} değil @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` statik bir semboldür: önce Sembol Türünü Dinamik olarak ayarlayın +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Bir sembol örneğinde seçilen yolun, bileşik yolun veya yazının yalnızca dolgu ve konturunun düz rengi değiştirilebilir @msg path must be a list of child indices path alt öğe dizinlerinden oluşan bir liste olmalıdır @msg no object at path {shown} in the symbol's art sembolün çiziminde {shown} yolunda nesne yok @msg give fill, stroke, opacity and/or blend fill, stroke, opacity ve/veya blend verin +@msg {key} cannot be set on a guide {key} bir kılavuzda ayarlanamaz +@msg {key} cannot be set on a blend {key} bir karışımda ayarlanamaz +@msg {key} cannot be set on an envelope {key} bir zarfta ayarlanamaz +@msg {key} cannot be set on a gradient mesh {key} bir degrade örgüde ayarlanamaz +@msg {key} cannot be set on a Repeat {key} bir tekrarda ayarlanamaz +@msg {key} cannot be set on a placed document {key} yerleştirilmiş bir belgede ayarlanamaz +@msg {key} cannot be set on an Intertwine group {key} bir iç içe geçirme grubunda ayarlanamaz +@msg stroke cannot be set on a symbol instance stroke bir sembol örneğinde ayarlanamaz +@msg fill on a symbol instance must be a solid color or none bir sembol örneğinin fill değeri düz renk veya none olmalıdır +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key}, Şekle Dönüştür veya Anahat Nesnesi efekti olmayan bir görüntüde ayarlanamaz @msg pass newName, registration, exportType, nineSlice and/or type newName, registration, exportType, nineSlice ve/veya type verin Text Flow Metin Akışı By Rows Satırlara Göre @@ -6373,6 +6508,7 @@ breakText Glyphs Glifler Create Swatch Legend Renk Örneği Açıklaması Oluştur Create Swatch Legend… Renk Örneği Açıklaması Oluştur… HEX HEX + Help Bar Yardım Çubuğu # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Metin Araçları Text Tweaker… Metin Ayarlayıcı… @@ -6438,3 +6574,44 @@ 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 plug-in of kind `{_2}`, not an importer `{_1}`, `{_2}` türünde bir eklentidir, içe aktarma eklentisi değildir +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}`, `{_2}` türünde bir eklentidir, dışa aktarma eklentisi değildir +@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 + +# Safe Mode + Safe Mode… Güvenli Mod… + Safe Mode Güvenli Mod + Help › Safe Mode… Yardım › Güvenli Mod… + Enable on Re-launch Sonraki Başlatmada Etkinleştir + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. VectorCraft'ın son başlatılması, {name} eklentisi yüklenirken durdu. Nedeni bu eklenti olmayabilir. + Disable and Continue Devre Dışı Bırak ve Devam Et + Start Normally Normal Başlat + Start in Safe Mode Güvenli Modda Başlat + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft Güvenli Mod'da: normal başlatılana kadar hiçbir eklenti yüklenmez veya çalıştırılmaz. + Disabled plug-ins Devre dışı eklentiler + No plug-ins are disabled. Devre dışı bırakılmış eklenti yok. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Güvenli Mod açık: VectorCraft normal başlatılana kadar eklentiler yüklenmez veya çalıştırılmaz +@msg give `id` or `file` (see plugin.safeMode) `id` veya `file` belirtin (bkz. plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) bu eklenti devre dışı değil (bkz. plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch Devre dışı eklentiler bir sonraki başlatma için kaydedilemedi +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Görünümü Genişlet: seçimdeki bir eklenti efekti Güvenli Mod'da genişletilemez + +# Align to Pixel Grid + Align to Pixel Grid Piksel Izgarasına Hizala +@msg only paths and compound paths can be aligned to the pixel grid piksel ızgarasına yalnızca yollar ve bileşik yollar hizalanabilir +@msg side is left or right, not `{s}` side left veya right olmalıdır, `{s}` değil diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 1b96c235e..f9dfd2d30 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -192,6 +192,10 @@ Anti-Alias Згладжування Anti-alias Rasters Згладжувати растрові зображення Anti-aliased Artwork Згладжений малюнок + Anti-aliasing Method Метод згладжування +antiAlias Sharp Різке + Crisp Чітке + Strong Сильне Anti-aliasing: Згладжування: Any Kind Будь-який тип Font Technology Технологія шрифту @@ -361,6 +365,13 @@ font-technology Variable Варіативні Apply Bullets Застосувати маркери Numbered List Нумерований список Apply Numbers Застосувати нумерацію + Bullets and Numbering… Маркери та нумерація… + Numbers Номери + Level: Рівень: + Bullet Character: Символ маркера: + Number Style: Стиль нумерації: + Left Indent: Відступ зліва: + First Line Indent: Відступ першого рядка: Convert to Text Перетворити на текст Filled Dot Заповнена крапка Dash Тире @@ -549,6 +560,11 @@ font-technology Variable Варіативні Color Table ({n} colors) Таблиця кольорів ({n} кольорів) Color Theme Колірна тема Color Themes Колірні теми + Color Wheel Колірне коло + Hue ring Кільце відтінків + Saturation and brightness Насиченість і яскравість + Color field Колірне поле + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Тип кольору: Color: Колір: Colorize Тонувати @@ -1081,6 +1097,14 @@ font-technology Variable Варіативні Export As SVG… Експортувати як SVG… Export Asset Експортувати ресурс Export Assets Експортувати ресурси + Export 3D Object Експортувати 3D-об’єкт + 3D Export Format Формат 3D-експорту + Export 3D Assets Експортувати 3D-ресурси +@msg {what} can't be exported in 3D: {e} {what} неможливо експортувати в 3D: {e} +@msg asset {id} isn't a 3D asset ресурс {id} не є 3D-ресурсом +@msg asset {id} is a 3D asset: assets.export3D writes it ресурс {id} є 3D-ресурсом: його записує assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) 3D-ресурсів ще немає: спершу зберіть об’єкт із 3D-ефектом (assets.add3D) +@msg select an object with a 3D effect (or give its ids) виберіть об’єкт із 3D-ефектом (або вкажіть його ідентифікатори) Export As… Експортувати як… Export CSS Експортувати CSS Export CSS… Експортувати CSS… @@ -1112,6 +1136,17 @@ font-technology Variable Варіативні Exporting {} Експорт {} Export… Експорт… Extrude & Bevel… Видавлювання та фаска… + 3D (Classic) 3D (класичні) + Extrude & Bevel (Classic)… Видавлювання та фаска (класичне)… + Surface Поверхня + Wireframe Каркас + No Shading Без затінення + Plastic Пластик + Highlight Intensity Інтенсивність відблиску + Highlight Size Розмір відблиску + Blend Steps Кроки переходу + Shading Color Колір затінення + Draw Hidden Faces Малювати приховані грані Eyedropper Піпетка Sample Color from Screen Взяти колір з екрана This system has no screen color sampler У цій системі немає засобу вибору кольору з екрана @@ -1169,6 +1204,7 @@ font-technology Variable Варіативні Stroke When Editable Обведення, яке можна редагувати Fill Color Колір заливки Fill New Type Objects With Placeholder Text Заповнювати нові текстові об’єкти текстом-заповнювачем + Start Lists From Typed Markers Починати списки з введених маркерів Fill Rule Правило заливки Fill with Placeholder Text Заповнити текстом-заповнювачем Fill: Заливка: @@ -1179,6 +1215,7 @@ font-technology Variable Варіативні Find Font… Знайти шрифт… Find Next Знайти далі Find Objects Знайти об’єкти + Compare with Image Порівняти із зображенням First Page Перша сторінка First-line Left Indent Відступ ліворуч у першому рядку Fisheye… Риб’яче око… @@ -1738,6 +1775,7 @@ file-filter Templates Шаблони Line {line} Рядок {line} Line {line}: {name}: {status} Рядок {line}: {name}: {status} Imported. Press OK to keep the imported set. Імпортовано. Натисніть «ОК», щоб зберегти імпортований набір. + Read Action Set File Прочитати файл набору дій Read Shortcut Set File Прочитати файл набору сполучень клавіш Import Matches Зіставлення імпорту Remember Import Matches Запам’ятати зіставлення імпорту @@ -1986,6 +2024,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 монітора @@ -2788,6 +2830,7 @@ digits Malayalam Малаялам Reset Transformation Скинути трансформацію Replace With Font Замінити шрифтом Replace With Font From Замінити шрифтом з + Find In Шукати в System Система Replaces the saved preset of that name. Замінює збережений набір із таким ім’ям. Replace… Замінити… @@ -3088,6 +3131,11 @@ digits Malayalam Малаялам Selected Swatches Вибрані зразки Selected Swatches Only Тільки вибрані зразки Selection Виділення +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Виділення рамкою + Touching Перетнуті + Fully Enclosed Повністю всередині + Drag Direction Напрямок перетягування Selection & Anchor Display Відображення виділення та опорних точок Selection Only Тільки виділення Selection Tool Інструмент «Виділення» @@ -3225,6 +3273,7 @@ digits Malayalam Малаялам Simulate Colored Paper Імітувати кольоровий папір Simulate Paper Імітувати папір Double Column Подвійний стовпець + Keep Tools Beside the Canvas Інструменти поруч із полотном Single Left Quotation Mark Одинарна лапка, що відкриває Single Quotes: Одинарні лапки: Single Right Quotation Mark Одинарна лапка, що закриває @@ -3474,6 +3523,15 @@ effect Points Точки Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Параметри Tate-chu-yoko… Tate-chu-yoko Settings Параметри Tate-chu-yoko + Composite Fonts… Складені шрифти… + Composite Fonts Складені шрифти + Export Composite Fonts Експорт складених шрифтів + Import Composite Fonts Імпорт складених шрифтів + Delete Font Видалити шрифт + Kanji Кандзі + Kana Кана + Numerals Цифри + Scale at Center Масштабувати від центру Up/Down: Вгору/вниз: Left/Right: Ліворуч/праворуч: Temperature Температура @@ -3527,6 +3585,8 @@ effect Points Точки Tile Type: Тип плитки: tile {number} плитка {number} Tiles are numbered across, then down; leave the range empty for every tile. Плитки нумеруються зліва направо, потім униз; Залишіть діапазон порожнім для всіх плиток. + Copies per Sheet (as many as fit, in a grid) Копій на аркуш (скільки поміститься, сіткою) + Gutter: Проміжок: Tint Відтінок Tip extends past the end point Кінчик виходить за кінцеву точку Tip on the end point Кінчик на кінцевій точці @@ -3822,6 +3882,7 @@ effect Points Точки Zoom to Selection Масштаб виділення Zoom Tool Інструмент «Масштаб» Zoom with Mouse Wheel Масштабувати колесом миші + Scroll Sideways with Cmd/Ctrl + Wheel Прокручувати по горизонталі колесом із Cmd/Ctrl Scrub Numeric Fields by Dragging Змінювати числові поля перетягуванням @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? файлів: {n} {folder} {note}. Показати папку пакета?|файлів: {n} {folder} {note}. Показати папку пакета?|файлів: {n} {folder} {note}. Показати папку пакета? @plural {n} linked file not found|{n} linked files not found не знайдено пов’язаних файлів: {n}|не знайдено пов’язаних файлів: {n}|не знайдено пов’язаних файлів: {n} @@ -3992,11 +4053,11 @@ axis Both Обидві Dock to Bottom Прикріпити внизу Dock Control Bar Прикріпити панель керування Drag to float the toolbar Перетягніть, щоб від’єднати панель інструментів - Drag to the window's left edge to dock Перетягніть до лівого краю вікна для прикріплення + Drag to the window's left or right edge to dock Перетягніть до лівого або правого краю вікна для прикріплення Float Tool Group Від’єднати групу інструментів @msg no library `{id}` немає бібліотеки `{id}` @msg select some type first спочатку виділіть текст -@msg kerning must be a number, "auto", or "optical" Кернінг має бути числом, «auto» або «optical» +@msg kerning must be a number, "auto", or "optical" kerning має бути числом, «auto» або «optical» @msg the selection has no gradient fill виділення не має градієнтної заливки @msg the selection has no gradient stroke виділення не має градієнтного обведення @msg the selection has no pattern fill виділення не має візерункової заливки @@ -4100,6 +4161,7 @@ axis Both Обидві @msg Targa files are RGB: colorModel rgb Файли Targa є RGB: colorModel rgb @msg Text Wrap: select a wrap object Обтікання текстом: виділіть об’єкт обтікання @msg This shortcut file is in a form VectorCraft can't read. Цей файл сполучень клавіш записано у форматі, який VectorCraft не може прочитати. +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` має бути "none", "sharp", "crisp" або "strong" @msg `aspect` must be a number (%) `aspect` має бути числом (%) @msg `at` must be [x, y] `at` має бути [x, y] @msg `color` must be a colour `color` має бути кольором @@ -4223,9 +4285,9 @@ axis Both Обидві @msg bad shortcut неприпустиме поєднання клавіш @msg bad stream length неприпустима довжина потоку @msg bad trailer неприпустимий трейлер -@msg balance must be between 0 and 100 баланс має бути від 0 до 100 +@msg balance must be between 0 and 100 balance має бути від 0 до 100 @msg batch step {_1} (`{id}`) failed: {e} крок пакету {_1} (`{id}`) не виконано: {e} -@msg brightness must be dark|mediumDark|mediumLight|light яскравість має бути dark|mediumDark|mediumLight|light +@msg brightness must be dark|mediumDark|mediumLight|light brightness має бути dark|mediumDark|mediumLight|light @msg can't encode the document: {0} не вдалося закодувати документ: {0} @msg can't parse shortcut `{c}` не вдалося розібрати поєднання `{c}` @msg can't parse shortcut `{s}` for `{k}` не вдалося розібрати поєднання `{s}` для `{k}` @@ -4250,7 +4312,7 @@ axis Both Обидві @msg compound paths can only contain paths складені контури можуть містити лише контури @msg compressed (RLE) bitmaps can't be read yet стислі (RLE) растрові зображення поки не читаються @msg damaged пошкоджено -@msg decimals must be {_1}–{_2} (got {_3}) число десяткових знаків має бути {_1}–{_2} (отримано {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals має бути {_1}–{_2} (отримано {_3}) @msg divide into 1 to {MAX_PIECES} slices розділіть на 1–{MAX_PIECES} фрагментів @msg effect {k} is `{_1}`, not `{id}` ефект {k} - це `{_1}`, а не `{id}` @msg empty brush stroke порожній мазок пензля @@ -4375,6 +4437,8 @@ axis Both Обидві @msg no such action set такого набору дій немає @msg the action sets changed; nothing was deleted набори дій змінилися; нічого не видалено @msg no action set to save немає набору дій для збереження +@msg the action set file can't be read in full файл набору дій неможливо прочитати повністю +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Завантажено «{name}»: пропущено {n} крок|Завантажено «{name}»: пропущено {n} кроки|Завантажено «{name}»: пропущено {n} кроків @msg not an action set file це не файл набору дій @msg the action set file is larger than 8 MiB файл набору дій більший за 8 MiB @msg the action set file holds more than 1,000 actions файл набору дій містить понад 1000 дій @@ -4449,9 +4513,9 @@ axis Both Обидві @msg replace: the object to replace was changed or deleted replace: об’єкт для заміни було змінено або видалено @msg pattern missing візерунок відсутній @msg patterns nested too deeply are filled with mid-grey надто глибоко вкладені візерунки заповнюються сірим -@msg percent must be above 0 and at most 6400, not {p} відсоток має бути більше 0 і не більше 6400, а не {p} +@msg percent must be above 0 and at most 6400, not {p} percent має бути більше 0 і не більше 6400, а не {p} @msg pick a colour, gradient or pattern to save as a swatch виберіть колір, градієнт або візерунок, щоб зберегти як зразок -@msg plane must be left, right or ground площина повинна бути left, right або ground +@msg plane must be left, right or ground plane має бути left, right або ground @msg plug-in ABI: {0} ABI модуля: {0} @msg plug-in exceeded its {0} модуль перевищив свій {0} @msg plug-in failed: {0} модуль завершився помилково: {0} @@ -4470,8 +4534,8 @@ axis Both Обидві @msg result would exceed the canvas (coordinates out of range) результат перевищив би полотно (координати поза діапазоном) @msg objects can't be nested more than 32 deep об’єкти не можна вкладати глибше ніж на 32 рівні @msg the SVG's elements are nested more than {0} deep елементи SVG вкладено глибше ніж на {0} рівнів -@msg scale must be a number from {_1} to {_2}, not {_3} масштаб має бути числом від {_1} до {_2}, а не {_3} -@msg scale must be a number from {_1} to {_2}, not {s} масштаб має бути числом від {_1} до {_2}, а не {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale має бути числом від {_1} до {_2}, а не {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale має бути числом від {_1} до {_2}, а не {s} @msg select (or target) objects whose appearance isn't basic виділіть (або вкажіть як об’єкт) об’єкти, оформлення яких не є основним @msg select a Live Paint group виділіть групу швидкої заливки @msg select a blend виділіть перехід @@ -4499,6 +4563,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 виділіть об’єкти, а не шари @@ -4736,11 +4807,15 @@ axis Both Обидві @msg its objects are nested too deeply об’єкти в них вкладені надто глибоко @msg its section ending with `%{end}` doesn't end розділ, що закінчується на `%{end}`, не закінчується @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) пензлі, які VectorCraft поки не вміє читати, не додано до панелі «Пензлі» ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet об’єкти з кількома заливками або обведеннями, ефектами або пензлями імпортовані в намальованому вигляді ({_1}): їх оформлення поки що не можна редагувати +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet об’єкти з кількома заливками або обведеннями, обведеннями змінної ширини, ефектами або пензлями імпортовані в намальованому вигляді ({_1}): їх оформлення поки що не можна редагувати +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding назва `{name}` не в UTF-8 і прочитана як {label}: якщо вона виглядає неправильно, відкрийте файл з іншим legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding назва `{name}` не в UTF-8, і її частини недійсні також у {label}: відкрийте файл з іншим legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) інші назви не в UTF-8 прочитано як {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it градієнт «{name}» не визначено у файлі: замість нього використовується суцільний колір @msg an image VectorCraft can't read was left out зображення, яке VectorCraft не може прочитати, пропущено @msg graphs come in as plain art, without their data or settings діаграми імпортуються як звичайна графіка, без їхніх даних і налаштувань @msg an image whose data is cut short was left out зображення з обрізаними даними пропущено +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers вбудоване зображення {_1} × {_2} пікселів зменшено до {_3} × {_4} пікселів, щоб зберегти шари @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel пов’язані зображення показуються як збережений у файлі перегляд низької роздільності, доки їхні файли не знайдено: відновіть зв’язки на панелі «Зв’язки» @msg linked type areas past VectorCraft's limits were left out пов’язані текстові області понад обмеження VectorCraft пропущено @msg type that changes writing direction inside a text object opens in the direction of its first character текст, що змінює напрямок письма всередині текстового об’єкта, відкривається в напрямку свого першого символу @@ -4802,6 +4877,7 @@ axis Both Обидві @msg object ids are non-negative integers, not {v} ідентифікатори об’єктів — невід’ємні цілі числа, а не {v} @msg {key} must be an array of non-negative integer object ids {key} має бути масивом ідентифікаторів об’єктів, які є невід’ємними цілими числами @msg invalid object id {value}: expected a non-negative integer неприпустимий ідентифікатор об’єкта {value}: очікується невід’ємне ціле число +@msg `{key}` entry {index} is {value}, not a non-negative integer object id елемент {index} у `{key}` дорівнює {value}, а не ідентифікатору об’єкта (невід’ємному цілому числу) @msg no such object id {value} об’єкт з ідентифікатором {value} не існує @msg id {value} is a layer, not an object ідентифікатор {value} належить шару, а не об’єкту @msg id {value} is a layer, not an object; move layers with layer.move ідентифікатор {value} належить шару, а не об’єкту; переміщуйте шари за допомогою layer.move @@ -4828,12 +4904,19 @@ axis Both Обидві @msg the Pixar file's image data is damaged дані зображення у файлі Pixar пошкоджені @msg the JPEG 2000 file can't be read не вдається прочитати файл JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content файл JPEG 2000 містить непідтримуваний колірний простір або вміст JPX +@msg the HEIC or HEIF file can't be read не вдається прочитати файл HEIC або HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read файл HEIC або HEIF містить зображення такого типу, яке не вдається прочитати +@msg HEIC and HEIF images can't be opened in this build зображення HEIC і HEIF не можна відкрити в цій збірці @msg the PSD image is too large to read ({_1} × {_2} pixels) зображення PSD завелике для читання ({_1} × {_2} пікс.) @msg zipped 32-bit PSD image data with prediction can't be read 32-бітні дані зображення PSD зі стисненням ZIP із передбаченням не читаються @msg {depth}-bit PSD files in this colour mode can't be read {depth}-бітні файли PSD у цьому колірному режимі не читаються @msg the PSD file is cut short файл PSD обрізаний @msg the PSD file's header is damaged заголовок файлу PSD пошкоджено @msg the PSD file's image data can't be read: {e} не вдається прочитати дані зображення файлу PSD: {e} +@msg 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 Відео De-Interlace… Усунення черезрядковості… De-Interlace Усунення черезрядковості @@ -4889,6 +4972,9 @@ axis Both Обидві Bottom Right Знизу праворуч Edge Край System Title Bar Системний рядок заголовка + VectorCraft Title Bar on Every Desktop Рядок заголовка VectorCraft на будь-якій стільниці + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Поки змінну середовища VECTORCRAFT_TITLEBAR задано, рядок заголовка вибирає вона. + System Title Bar takes precedence over this option while it is on. Поки ввімкнено «Системний рядок заголовка», він має пріоритет над цим параметром. Value Axis Вісь значень On Left Side Ліворуч On Right Side Праворуч @@ -4935,7 +5021,7 @@ axis Both Обидві @msg before must name a panel before має вказувати панель @msg docking action contains an unknown panel or an excessive split path Дія прикріплення містить невідому панель або надто довгий шлях поділів @msg group must be true or false group має мати значення true або false -@msg panel is not in a dock group панель не входить до групи дока +@msg panel is not in a dock group panel не входить до групи дока @msg only a dock group above or below others collapses, not one beside another згорнути можна лише групу дока над або під іншими, а не поруч з ними @msg icon must be true or false icon має бути true або false @msg icon only docks a panel in the icon column, with no anchor icon лише закріплює панель у стовпці значків, без anchor @@ -5100,6 +5186,11 @@ 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 {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: це не зображення PNG, JPEG, WebP, TIFF чи BMP +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ розмір зображення {iw} × {ih} пікселів, а монтажної області за цієї ширини {rw} × {rh} пікселів: їхні пропорції відрізняються +@msg `threshold` must be a number from 0 to 1 `threshold` має бути числом від 0 до 1 +@msg background {v}: a color such as "#808080" фон {v}: колір, наприклад "#808080" +@msg comparing with an image file isn't available in the browser порівняння з файлом зображення недоступне в браузері @msg an image of {w} × {h} pixels is shown at reduced size зображення розміром {w} × {h} пікселів показано в зменшеному розмірі @msg the image can't be read as a texture зображення не вдається прочитати як текстуру Basic Базовий @@ -5298,11 +5389,19 @@ lighting Light Світло @msg too many library panels were opened in this session у цьому сеансі відкрито забагато панелей бібліотек @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Зображення {_1} × {_2} пікселів, понад {LARGE_TRACE_SIDE} з одного боку: трасування виконується, але повільніше. Стиль із нижчою точністю або менша копія зображення трасуються швидше. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Зображення трасовано в розмірі {_1} × {_2} пікселів, зменшене з {_3} × {_4} (щонайбільше {_5} мегапікселів). +@msg byColor must be true or false byColor має мати значення true або false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor не може розділити групи автогрупування: трасуйте без автогрупування +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor не може перегрупувати трасування з активним ефектом Pathfinder: розгорніть без byColor Snapping Прив’язка Snap to Tangent Прив’язати до дотичної Snap to Perpendicular Прив’язати до перпендикуляра @msg unknown snap option `{key}` (use {options}) невідомий параметр прив’язки `{key}` (використовуйте {options}) Gap Length: Довжина проміжку: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Режим «Плоский логотип» не використовує keepColors: він трасує кольори, вказані в logoColors, або знайдені ним кольори. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Нічого не трасовано: жоден із кольорів, знайдених під час трасування, не відповідає keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Нічого не трасовано: «Ігнорувати колір» пропускає всі області кольорів, перелічених у keepColors. +@msg Nothing was traced: the colors that match keepColors produce no paths. Нічого не трасовано: кольори, що відповідають keepColors, не дають жодного контуру. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: жоден із кольорів, знайдених під час трасування, не відповідає {c}, тому з нього нічого не трасується. @msg `colorGroup` needs `library: "document"` для `colorGroup` потрібен `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` для `colorGroup` потрібен `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` для `colorGroup` потрібен `mode: "color"` @@ -5417,11 +5516,14 @@ axis Y Axis Вісь Y @msg Running “{name}”… Виконується «{name}»… @msg a live effect runs while the document is drawn, so it can't be `heavy` динамічний ефект виконується під час відмальовування документа, тому він не може бути `heavy` @msg an exporter always gets an export's budget: leave `budget` out модуль експорту завжди отримує бюджет експорту: не вказуйте `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» запитує великий обчислювальний бюджет (кожен запуск до {minutes} хвилин і в {times} разів більше інструкцій, ніж зазвичай): встановіть його з `allowHeavy: true`, коли користувач погодиться +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants «{_1}» запитує великий обчислювальний бюджет (кожен запуск до {minutes} хвилин і в {times} разів більше інструкцій, ніж зазвичай): його має схвалити людина в настільному застосунку, або передайте `allowHeavy: true` у сеансі, запущеному з --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Встановити «{name}», якому потрібні ці дозволи? ID: {id} ID: {id} Version: {version} Версія: {version} + Install “{name}” in place of the plug-in with the same ID? Встановити «{name}» замість модуля з тим самим ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Цей файл не є модулем, установленим раніше з цим ID. Він отримає налаштування й кеш, збережені для цього ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees «{name}» відрізняється від модуля з ID {_1} і отримав би збережені для нього налаштування й кеш: встановіть його з `replace: true`, коли користувач погодиться File SHA-256: {hash} SHA-256 файлу: {hash} Read the objects it is given Читати передані йому об’єкти Change, add and delete objects in the document Змінювати, додавати й видаляти об’єкти в документі @@ -5429,21 +5531,43 @@ 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 Записувати документ у файл власного формату Show its progress, see Cancel and write log lines Показувати свій поступ, помічати скасування і записувати рядки журналу -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} дозволу `progress` потрібен рівень API модулів {PROGRESS_API}: укажіть `api` {PROGRESS_API} + Keep settings of its own Зберігати власні налаштування + Keep a cache of its own, which you can clear Зберігати власний кеш, який можна очистити + Clear Plug-in Cache Очистити кеш модуля +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} дозволу `{permission}` потрібен рівень API модулів {PROGRESS_API}: укажіть `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} модуль імпортує `env.{name}` з іншим типом, ніж у хоста: у хоста тип {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` модуль імпортує `env.{name}`, який модуль може використовувати лише на рівні API модулів {PROGRESS_API} (`api` {PROGRESS_API}) і з дозволом `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network модуль імпортує `{from}.{name}`, який хост не надає: модуль може імпортувати лише `env.vc_progress`, `env.vc_cancelled` і `env.vc_log` (рівень API модулів 2), без WASI, файлів і мережі +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` модуль імпортує `env.{name}`, який модуль може використовувати лише на рівні API модулів {PROGRESS_API} (`api` {PROGRESS_API}) і з дозволом `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network модуль імпортує `{from}.{name}`, який хост не надає: модуль може імпортувати лише функції `env.vc_*`, перелічені в docs/plugins.md (рівень API модулів 2), без WASI, файлів і мережі @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` дорівнює {_1}; цей хост підтримує рівні API модулів від 1 до {API_LEVEL} +@msg `paintOutput` may only be true `paintOutput` може бути лише true +@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}` @msg at most {MAX_PERMISSIONS} `permissions` щонайбільше {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` дозвіл `{shown}` не стосується модуля виду `{_1}` @msg permission `{shown}` is listed twice дозвіл `{shown}` указано двічі +@msg only a filter declares `inputs` `inputs` може оголошувати лише фільтр +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance в `inputs` указано невідомий вид об’єкта `{shown}`: використовуйте type, image або symbolInstance +@msg `inputs` lists `{shown}` twice в `inputs` `{shown}` указано двічі +@msg `inputs` needs `api` 2 or later для `inputs` потрібен `api` 2 або новіший +@msg `inputs` needs the permission `doc.read` для `inputs` потрібен дозвіл `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) бюджет пам’яті (об’єкту {_1} потрібно {size} байтів вхідних даних, залишилося {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) бюджет об’єктів (понад {MAX_OBJECTS} об’єктів лише для читання) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it дозвіл `{shown}` зарезервовано для пізнішого API модулів; цей хост його не надає @msg unknown permission `{shown}` невідомий дозвіл `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запитує дозволи {asked}: установіть його з `grant`, де їх перелічено, коли користувач погодиться +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants «{name}» запитує дозволи {asked}: їх має схвалити людина в настільному застосунку, або передайте `grant` у сеансі, запущеному з --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` і `allowHeavy` діють лише в сеансі, запущеному з --allow-plugin-grants: схваліть плагін у настільному застосунку +@msg Only the person at the keyboard can approve installing a plug-in Схвалити встановлення плагіна може лише людина за клавіатурою @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed «{name}» не отримав дозволу `{p}`, потрібного для його результату: нічого не змінено +@msg a heavy filter runs in the background, so it can't declare `commands` важкий фільтр виконується у фоні, тому не може оголошувати `commands` +@msg at most {MAX_COMMANDS} `commands` щонайбільше {MAX_COMMANDS} `commands` +@msg command {name} is declared twice команду {name} оголошено двічі +@msg command {name}: `label` must be 1-64 printable characters команда {name}: `label` має містити 1-64 друковані символи +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter команда {name}: використайте id з 1-{MAX_COMMAND_ID} символів a-z 0-9 _, що починається з літери +@msg command {name}: {e} команда {name}: {e} +@msg only a filter or a live effect declares `commands` `commands` оголошує лише фільтр або динамічний ефект +@msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed модуль має не той SHA-256, що вказаний у `hash`: нічого не встановлено # Actions panel: action sets (#3712). New Set… Новий набір… @@ -5864,6 +5988,8 @@ breakText Glyphs Гліфи File + Data Set Name Файл + назва набору даних Data Set Name Назва набору даних @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` має бути `fileNumber`, `fileDataSetName` або `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Кожен набір даних зберігається поверх того самого файлу, у якому лишається тільки останній набір даних. +@msg save the document with Save As before Save and Close збережіть документ командою «Зберегти як» перед «Зберегти й закрити» @msg no free data set file name in destination folder У папці призначення немає вільного імені файлу набору даних # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -5984,9 +6110,20 @@ breakText Glyphs Гліфи Reset Symbol Instance Скинути екземпляр символу @msg type must be static or dynamic, not {v} type має бути static або dynamic, а не {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` — статичний символ: спершу встановіть для нього тип символу «Динамічний» +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change У контуру, складеного контуру або тексту, виділеного в екземплярі символу, можна змінити лише суцільний колір заливки й обведення @msg path must be a list of child indices path має бути списком індексів дочірніх об’єктів @msg no object at path {shown} in the symbol's art у графіці символу немає об’єкта за шляхом {shown} @msg give fill, stroke, opacity and/or blend вкажіть fill, stroke, opacity та/або blend +@msg {key} cannot be set on a guide {key} не можна задати для напрямної +@msg {key} cannot be set on a blend {key} не можна задати для переходу +@msg {key} cannot be set on an envelope {key} не можна задати для оболонки +@msg {key} cannot be set on a gradient mesh {key} не можна задати для градієнтної сітки +@msg {key} cannot be set on a Repeat {key} не можна задати для повторення +@msg {key} cannot be set on a placed document {key} не можна задати для розміщеного документа +@msg {key} cannot be set on an Intertwine group {key} не можна задати для групи переплетення +@msg stroke cannot be set on a symbol instance stroke не можна задати для екземпляра символу +@msg fill on a symbol instance must be a solid color or none fill екземпляра символу має бути суцільним кольором або none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it {key} не можна задати для зображення без ефекту «Перетворити на фігуру» або «Обвести об’єкт» @msg pass newName, registration, exportType, nineSlice and/or type вкажіть newName, registration, exportType, nineSlice та/або type Text Flow Потік тексту By Rows За рядками @@ -6016,6 +6153,7 @@ breakText Glyphs Гліфи Create Swatch Legend Створити легенду зразків Create Swatch Legend… Створити легенду зразків… HEX HEX + Help Bar Панель підказок # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Інструменти тексту Text Tweaker… Налаштування тексту… @@ -6081,3 +6219,44 @@ 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 plug-in of kind `{_2}`, not an importer `{_1}` є модулем виду `{_2}`, а не модулем імпорту +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_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` + +# Safe Mode + Safe Mode… Безпечний режим… + Safe Mode Безпечний режим + Help › Safe Mode… Довідка › Безпечний режим… + Enable on Re-launch Увімкнути під час наступного запуску + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Останній запуск VectorCraft зупинився під час завантаження модуля {name}. Можливо, причина не в ньому. + Disable and Continue Вимкнути й продовжити + Start Normally Запустити у звичайному режимі + Start in Safe Mode Запустити в безпечному режимі + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft працює в безпечному режимі: модулі не завантажуються й не виконуються, доки програму не буде запущено у звичайному режимі. + Disabled plug-ins Вимкнені модулі + No plug-ins are disabled. Немає вимкнених модулів. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Безпечний режим увімкнено: модулі не завантажуються й не виконуються, доки VectorCraft не буде запущено у звичайному режимі +@msg give `id` or `file` (see plugin.safeMode) укажіть `id` або `file` (див. plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) цей модуль не вимкнено (див. plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch Не вдалося зберегти вимкнені модулі для наступного запуску +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Розбір оформлення: ефект модуля у виділенні не можна розібрати в безпечному режимі + +# Align to Pixel Grid + Align to Pixel Grid Вирівняти за піксельною сіткою +@msg only paths and compound paths can be aligned to the pixel grid за піксельною сіткою можна вирівняти лише контури і складені контури +@msg side is left or right, not `{s}` side — це left або right, а не `{s}` diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ce98bc978..31a0d99ff 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -216,6 +216,10 @@ Anti-Alias Khử răng cưa Anti-alias Rasters Khử răng cưa ảnh raster Anti-aliased Artwork Tác phẩm khử răng cưa + Anti-aliasing Method Phương pháp khử răng cưa +antiAlias Sharp Sắc nét + Crisp Rõ nét + Strong Đậm Anti-aliasing: Khử răng cưa: Any Kind Bất kỳ loại Font Technology Công nghệ phông chữ @@ -422,6 +426,13 @@ font-technology Variable Biến thiên Apply Bullets Áp dụng dấu đầu dòng Numbered List Danh sách đánh số Apply Numbers Áp dụng đánh số + Bullets and Numbering… Dấu đầu dòng và đánh số… + Numbers Số + Level: Cấp độ: + Bullet Character: Ký tự đầu dòng: + Number Style: Kiểu đánh số: + Left Indent: Lề trái: + First Line Indent: Thụt lề dòng đầu: Convert to Text Chuyển thành văn bản Filled Dot Chấm đặc Dash Gạch ngang @@ -642,6 +653,11 @@ font-technology Variable Biến thiên Color Table ({n} colors) Bảng màu ({n} màu) Color Theme Bộ màu Color Themes Bộ màu + Color Wheel Bánh xe màu + Hue ring Vòng sắc độ + Saturation and brightness Độ bão hòa và độ sáng + Color field Vùng chọn màu + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Loại màu: Color: Màu: Colorization Tô màu @@ -997,6 +1013,7 @@ font-technology Variable Biến thiên Dots Chấm Double Click To Isolate Bấm đúp để cô lập Double Column Hai cột + Keep Tools Beside the Canvas Công cụ cạnh vùng vẽ Double Left Quotation Marks Dấu ngoặc kép trái đôi Double Quotes: Dấu ngoặc kép: Double Right Quotation Marks Dấu ngoặc kép phải đôi @@ -1028,7 +1045,7 @@ font-technology Variable Biến thiên Drag to float as a toolbar Kéo để thả nổi thanh công cụ Drag to float the toolbar Kéo để thả nổi thanh công cụ Drag to move Kéo để di chuyển - Drag to the window's left edge to dock Kéo đến cạnh trái cửa sổ để neo + Drag to the window's left or right edge to dock Kéo đến cạnh trái hoặc phải cửa sổ để neo Step up or down Tăng hoặc giảm một bước Drag values or labels. Double-click a value to type. Kéo giá trị hoặc nhãn. Bấm đúp vào giá trị để nhập. Draw Vẽ @@ -1244,6 +1261,14 @@ font-technology Variable Biến thiên Export As SVG… Xuất thành SVG… Export Asset Xuất asset Export Assets Xuất asset + Export 3D Object Xuất đối tượng 3D + 3D Export Format Định dạng xuất 3D + Export 3D Assets Xuất asset 3D +@msg {what} can't be exported in 3D: {e} không thể xuất {what} dưới dạng 3D: {e} +@msg asset {id} isn't a 3D asset asset {id} không phải là asset 3D +@msg asset {id} is a 3D asset: assets.export3D writes it asset {id} là asset 3D: assets.export3D sẽ ghi nó +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) chưa có asset 3D nào: hãy thu thập trước một đối tượng có hiệu ứng 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) hãy chọn một đối tượng có hiệu ứng 3D (hoặc cung cấp id của nó) Export As… Xuất thành… Export CSS Xuất CSS Export CSS… Xuất CSS… @@ -1277,6 +1302,17 @@ font-technology Variable Biến thiên Exporting {} Đang xuất {} Export… Xuất… Extrude & Bevel… Kéo dài & vát… + 3D (Classic) 3D (cổ điển) + Extrude & Bevel (Classic)… Kéo dài & vát (cổ điển)… + Surface Bề mặt + Wireframe Khung dây + No Shading Không tô bóng + Plastic Nhựa + Highlight Intensity Cường độ điểm sáng + Highlight Size Kích thước điểm sáng + Blend Steps Số bước hòa trộn + Shading Color Màu tô bóng + Draw Hidden Faces Vẽ các mặt ẩn Eyedropper Ống hút màu Sample Color from Screen Lấy mẫu màu từ màn hình This system has no screen color sampler Hệ thống này không có công cụ lấy mẫu màu trên màn hình @@ -1345,6 +1381,7 @@ digits Malayalam Malayalam Highlight: Làm nổi bật: Stroke When Editable Nét khi có thể chỉnh sửa Fill New Type Objects With Placeholder Text Tô đối tượng chữ mới bằng văn bản giữ chỗ + Start Lists From Typed Markers Bắt đầu danh sách từ ký hiệu đã gõ Fill Rule Quy tắc tô Fill with Placeholder Text Tô bằng văn bản giữ chỗ Fill: Tô: @@ -1389,6 +1426,7 @@ digits Malayalam Malayalam Find Linked Files in Folder… Tìm tệp liên kết trong thư mục… Find Next Tìm tiếp Find Objects Tìm đối tượng + Compare with Image So sánh với ảnh Fine Dots Chấm mịn Simple Đơn giản @@ -1888,6 +1926,7 @@ digits Hindi Hindi Line {line} Dòng {line} Line {line}: {name}: {status} Dòng {line}: {name}: {status} Imported. Press OK to keep the imported set. Đã nhập. Nhấn OK để giữ bộ đã nhập. + Read Action Set File Đọc tệp bộ hành động Read Shortcut Set File Đọc tệp bộ phím tắt Import Matches Cặp ghép khi nhập Remember Import Matches Ghi nhớ cặp ghép khi nhập @@ -2004,6 +2043,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 @@ -2872,6 +2912,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} @@ -3062,6 +3105,7 @@ mark-style Japanese Kiểu Nhật Reset Transformation Đặt lại biến đổi Replace With Font Thay thế bằng phông chữ Replace With Font From Thay thế bằng phông chữ từ + Find In Tìm trong System Hệ thống Replace “{name}”, the file this document was opened from? Thay thế “{name}”, tệp mà tài liệu này được mở từ đó? Replaces the saved preset of that name. Thay thế thiết lập sẵn đã lưu có tên đó. @@ -3416,6 +3460,11 @@ mark-style Japanese Kiểu Nhật Selected Swatches Mẫu màu đã chọn Selected Swatches Only Chỉ mẫu màu đã chọn Selection Vùng chọn +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection Chọn bằng khung + Touching Đối tượng chạm khung + Fully Enclosed Nằm hoàn toàn trong khung + Drag Direction Hướng kéo Selection & Anchor Display Hiển thị vùng chọn & điểm neo Selection Only Chỉ vùng chọn Selection Tool Công cụ Chọn @@ -3791,6 +3840,9 @@ mark-style Japanese Kiểu Nhật Symbols Ký hiệu System Layout Bố cục hệ thống System Title Bar Thanh tiêu đề hệ thống + VectorCraft Title Bar on Every Desktop Thanh tiêu đề VectorCraft trên mọi màn hình nền + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Khi biến môi trường VECTORCRAFT_TITLEBAR được đặt, biến này chọn thanh tiêu đề. + System Title Bar takes precedence over this option while it is on. Khi Thanh tiêu đề hệ thống được bật, tùy chọn đó được ưu tiên hơn tùy chọn này. Tab Tab Tab Stops Vị trí tab Table Bảng @@ -3805,6 +3857,15 @@ mark-style Japanese Kiểu Nhật Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Cài đặt Tate-chu-yoko… Tate-chu-yoko Settings Cài đặt Tate-chu-yoko + Composite Fonts… Phông chữ tổ hợp… + Composite Fonts Phông chữ tổ hợp + Export Composite Fonts Xuất phông chữ tổ hợp + Import Composite Fonts Nhập phông chữ tổ hợp + Delete Font Xóa phông chữ + Kanji Kanji + Kana Kana + Numerals Chữ số + Scale at Center Co giãn từ tâm Up/Down: Lên/Xuống: Left/Right: Trái/Phải: Temperature Nhiệt độ màu @@ -3871,6 +3932,8 @@ mark-style Japanese Kiểu Nhật tile {number} ô {number} Tiles are numbered across, then down; leave the range empty for every tile. Các ô được đánh số từ ngang sang dọc; để trống phạm vi để in tất cả ô. Tilt Độ nghiêng + Copies per Sheet (as many as fit, in a grid) Nhiều bản sao trên một tờ (xếp vừa bao nhiêu in bấy nhiêu, theo lưới) + Gutter: Khoảng cách: Tint Tông màu Tints Sắc pha Tints and Shades Sắc pha và bóng @@ -4188,6 +4251,7 @@ mark-style Japanese Kiểu Nhật Zoom to Selection Thu phóng đến vùng chọn Zoom Tool Công cụ thu phóng Zoom with Mouse Wheel Thu phóng bằng con lăn chuột + Scroll Sideways with Cmd/Ctrl + Wheel Cuộn ngang bằng Cmd/Ctrl + con lăn @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {n} tệp trong {folder} {note}. Hiện thư mục gói? @plural {n} folder or file couldn't be read|{n} folders or files couldn't be read {n} thư mục hoặc tệp không thể đọc được @plural {n} folder skipped, such as another app's or the system's folder or a hidden one|{n} folders skipped, such as other apps' and the system's folders and hidden ones Đã bỏ qua {n} thư mục, chẳng hạn thư mục của ứng dụng khác, thư mục hệ thống và thư mục ẩn @@ -4253,6 +4317,7 @@ effect Soft Mềm lighting Light Đèn theme Appearance Giao diện @msg "{0}" is not a colour: write it as #rrggbb "{0}" không phải là màu: viết dưới dạng #rrggbb +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` phải là "none", "sharp", "crisp" hoặc "strong" @msg `aspect` must be a number (%) `aspect` phải là một số (%) @msg `at` must be [x, y] `at` phải là [x, y] @msg `color` must be a colour `color` phải là một màu @@ -4378,6 +4443,7 @@ theme Appearance Giao diện @msg an image VectorCraft can't read was left out một ảnh VectorCraft không đọc được đã bị bỏ qua @msg graphs come in as plain art, without their data or settings biểu đồ được nhập dưới dạng hình vẽ thường, không kèm dữ liệu và thiết lập của chúng @msg an image whose data is cut short was left out một ảnh bị cắt ngắn dữ liệu đã bị bỏ qua +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers một ảnh nhúng {_1} × {_2} điểm ảnh đã được thu nhỏ còn {_3} × {_4} điểm ảnh để giữ các lớp @msg an object can't be read không thể đọc một đối tượng @msg an object in perspective would leave the edited grid đối tượng trong phối cảnh sẽ rời khỏi lưới đang chỉnh sửa @msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Một đối tượng chỉ nhận một hiệu ứng 3D. Hãy chỉnh sửa tùy chọn của hiệu ứng hiện có hoặc xóa nó trước. @@ -4411,7 +4477,7 @@ theme Appearance Giao diện @msg bad shortcut phím tắt không hợp lệ @msg bad stream length độ dài luồng không hợp lệ @msg bad trailer trailer không hợp lệ -@msg balance must be between 0 and 100 cân bằng phải nằm trong khoảng 0 đến 100 +@msg balance must be between 0 and 100 balance phải nằm trong khoảng 0 đến 100 @msg batch step {_1} (`{id}`) failed: {e} bước hàng loạt {_1} (`{id}`) thất bại: {e} @msg before must name a panel before phải đặt tên bảng @msg below and above must name a panel floating in another group below và above phải đặt tên bảng đang thả nổi trong nhóm khác @@ -4466,7 +4532,7 @@ theme Appearance Giao diện @msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Phục hồi dữ liệu không có nơi lưu bản sao: đặt Tùy chọn → Xử lý tệp → Phục hồi dữ liệu → Thư mục (recoveryFolder) @msg Data Recovery can't keep copies in {path}: {e} Phục hồi dữ liệu không thể lưu bản sao vào {path}: {e} @msg Plug-ins can't be loaded from {path}: {e} Không thể tải plug-in từ {path}: {e} -@msg decimals must be {_1}–{_2} (got {_3}) số thập phân phải từ {_1}–{_2} (nhận được {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals phải từ {_1}–{_2} (nhận được {_3}) @msg direct-select anchor points to cut at (not the end points of open paths) chọn trực tiếp các điểm neo để cắt (không phải điểm cuối của đường dẫn mở) @msgplural Discarded {n} recovered document|Discarded {n} recovered documents Đã loại bỏ {n} tài liệu đã phục hồi @msg divide into 1 to {MAX_PIECES} slices chia thành 1 đến {MAX_PIECES} lát cắt @@ -4506,7 +4572,7 @@ theme Appearance Giao diện @msg icon must be true or false icon phải là true hoặc false @msg icon only docks a panel in the icon column, with no anchor icon chỉ neo bảng vào cột biểu tượng, không có anchor @msg column docks a set as a dock column, with no anchor or icon column neo một bộ thành cột neo, không có anchor hay icon -@msg panel is not in a dock group bảng không thuộc nhóm nào của dock +@msg panel is not in a dock group panel không thuộc nhóm nào của dock @msg only a dock group above or below others collapses, not one beside another chỉ thu gọn được nhóm dock nằm trên hoặc dưới các nhóm khác, không phải nhóm nằm cạnh @msg guides are locked đường căn bị khóa @msg handle must be in or out handle phải là in hoặc out @@ -4517,9 +4583,9 @@ theme Appearance Giao diện @msg image would be too large; lower the resolution ảnh sẽ quá lớn; hãy giảm độ phân giải @msg Imported library {name} Đã nhập thư viện {name} @msg Imported {what}: {_1} Đã nhập {what}: {_1} -@msg index must be a number or first, previous, next or last chỉ mục phải là số hoặc first, previous, next hoặc last -@msg index must be a number or first, previous, next or last, not `{other}` chỉ mục phải là số hoặc first, previous, next hoặc last, không phải `{other}` -@msg index must be ≥ 0 chỉ mục không được âm +@msg index must be a number or first, previous, next or last index phải là số hoặc first, previous, next hoặc last +@msg index must be a number or first, previous, next or last, not `{other}` index phải là số hoặc first, previous, next hoặc last, không phải `{other}` +@msg index must be ≥ 0 index phải ≥ 0 @msg Installed plug-in {_1} Đã cài đặt plug-in {_1} @msg installing from a path is not available on the web; pass the module as `dataBase64` cài đặt từ đường dẫn không khả dụng trên web; hãy truyền mô-đun dưới dạng `dataBase64` @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) lỗi nội bộ trong `{cmd}`: {msg} (tài liệu được giữ nguyên; vui lòng báo cáo lỗi này) @@ -4680,6 +4746,8 @@ theme Appearance Giao diện @msg no such action set không có bộ hành động như vậy @msg the action sets changed; nothing was deleted các bộ hành động đã thay đổi; không có gì bị xóa @msg no action set to save không có bộ hành động nào để lưu +@msg the action set file can't be read in full không thể đọc hết tệp bộ hành động +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Đã tải “{name}”: bỏ qua {n} bước @msg not an action set file không phải tệp bộ hành động @msg the action set file is larger than 8 MiB tệp bộ hành động lớn hơn 8 MiB @msg the action set file holds more than 1,000 actions tệp bộ hành động chứa hơn 1.000 hành động @@ -4748,6 +4816,7 @@ theme Appearance Giao diện @msg object ids are non-negative integers, not {v} id đối tượng là số nguyên không âm, không phải {v} @msg {key} must be an array of non-negative integer object ids {key} phải là một mảng id đối tượng là số nguyên không âm @msg invalid object id {value}: expected a non-negative integer id đối tượng {value} không hợp lệ: cần một số nguyên không âm +@msg `{key}` entry {index} is {value}, not a non-negative integer object id mục {index} của `{key}` là {value}, không phải id đối tượng (số nguyên không âm) @msg no such object id {value} không có đối tượng nào có id {value} @msg id {value} is a layer, not an object id {value} là một lớp, không phải đối tượng @msg id {value} is a layer, not an object; move layers with layer.move id {value} là một lớp, không phải đối tượng; hãy di chuyển lớp bằng layer.move @@ -4755,7 +4824,10 @@ theme Appearance Giao diện @msg object {id} is not a path đối tượng {id} không phải là đường dẫn @msg object {id} is not a selected top-level object đối tượng {id} không phải là đối tượng cấp cao nhất đã chọn @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) các cọ mà VectorCraft chưa đọc được đã bị bỏ khỏi bảng Cọ ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet đối tượng có nhiều tô hoặc nét, hiệu ứng hoặc cọ đã vào dưới dạng bề ngoài ({_1}): diện mạo chưa thể chỉnh sửa +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet đối tượng có nhiều tô hoặc nét, nét có độ rộng thay đổi, hiệu ứng hoặc cọ đã vào dưới dạng bề ngoài ({_1}): diện mạo chưa thể chỉnh sửa +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding tên `{name}` không phải UTF-8 và đã được đọc theo {label}: nếu trông sai, hãy mở tệp với legacyNameEncoding khác +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding tên `{name}` không phải UTF-8, và một số phần cũng không hợp lệ trong {label}: hãy mở tệp với legacyNameEncoding khác +@msg more names that aren't UTF-8 were read as {label} ({count}) thêm các tên không phải UTF-8 đã được đọc theo {label} ({count}) @msg Offset Path: select paths Đường dẫn độ lệch: hãy chọn đường dẫn @msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Đường dẫn độ lệch: độ lệch không để lại gì từ các đường dẫn đã chọn (độ lệch vào trong lớn hơn hình) @msg onto must name a floating panel onto phải đặt tên bảng đang thả nổi @@ -4779,9 +4851,9 @@ theme Appearance Giao diện @msg PDF settings are not an object Cài đặt PDF không phải là đối tượng @msg PDF security settings are not an object Cài đặt bảo mật PDF không phải là đối tượng @msg PDF writer error: {0} Lỗi ghi PDF: {0} -@msg percent must be above 0 and at most 6400, not {p} phần trăm phải lớn hơn 0 và tối đa 6400, không phải {p} +@msg percent must be above 0 and at most 6400, not {p} percent phải lớn hơn 0 và tối đa 6400, không phải {p} @msg pick a colour, gradient or pattern to save as a swatch hãy chọn màu, chuyển sắc hoặc họa tiết để lưu làm mẫu màu -@msg plane must be left, right or ground mặt phẳng phải là left, right hoặc ground +@msg plane must be left, right or ground plane phải là left, right hoặc ground @msg plug-in ABI: {0} ABI plug-in: {0} @msg plug-in exceeded its {0} plug-in đã vượt quá {0} @msg plug-in failed: {0} plug-in thất bại: {0} @@ -4843,8 +4915,8 @@ theme Appearance Giao diện @msg Saving Đang lưu @msg Saving recovery data for {_1} Đang lưu dữ liệu phục hồi cho {_1} @msg Saving {_1} Đang lưu {_1} -@msg scale must be a number from {_1} to {_2}, not {_3} tỷ lệ phải là số từ {_1} đến {_2}, không phải {_3} -@msg scale must be a number from {_1} to {_2}, not {s} tỷ lệ phải là số từ {_1} đến {_2}, không phải {s} +@msg scale must be a number from {_1} to {_2}, not {_3} scale phải là số từ {_1} đến {_2}, không phải {_3} +@msg scale must be a number from {_1} to {_2}, not {s} scale phải là số từ {_1} đến {_2}, không phải {s} @msg searching a folder isn't available on the web tìm kiếm trong thư mục không khả dụng trên web @msg select (or target) objects whose appearance isn't basic chọn (hoặc đặt mục tiêu) đối tượng có diện mạo không cơ bản @msg select a blend hãy chọn một đối tượng hòa trộn @@ -4869,6 +4941,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 @@ -4876,7 +4955,7 @@ theme Appearance Giao diện @msg select paths or anchor points chọn đường dẫn hoặc điểm neo @msg select paths to join hãy chọn đường dẫn để nối @msg select some type first hãy chọn chữ trước -@msg kerning must be a number, "auto", or "optical" Kerning phải là số, “auto” hoặc “optical” +@msg kerning must be a number, "auto", or "optical" kerning phải là số, “auto” hoặc “optical” @msg the selection has no gradient fill vùng chọn không có tô chuyển sắc @msg the selection has no gradient stroke vùng chọn không có nét chuyển sắc @msg the selection has no pattern fill vùng chọn không có tô họa tiết @@ -4976,6 +5055,10 @@ 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 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}) @msg the PCX file is cut short tệp PCX bị cắt ngắn @msg the PCX file's header is damaged phần đầu của tệp PCX bị hỏng @@ -4985,6 +5068,9 @@ theme Appearance Giao diện @msg the Pixar file's image data is damaged dữ liệu ảnh của tệp Pixar bị hỏng @msg the JPEG 2000 file can't be read không thể đọc tệp JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content tệp JPEG 2000 có không gian màu hoặc nội dung JPX không được hỗ trợ +@msg the HEIC or HEIF file can't be read không thể đọc tệp HEIC hoặc HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read tệp HEIC hoặc HEIF chứa loại ảnh không thể đọc được +@msg HEIC and HEIF images can't be opened in this build không thể mở ảnh HEIC và HEIF trong bản dựng này @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ở @msg the scale is not a usable number tỷ lệ không phải là số dùng được @@ -5104,6 +5190,11 @@ theme Appearance Giao diện @msg {tool} isn't in a toolbar group of several tools {tool} không nằm trong nhóm thanh công cụ có nhiều công cụ Load EPS into Clipboard Tải EPS vào bảng tạm @msg an image of {w} × {h} pixels is too large to draw ảnh {w} × {h} điểm ảnh quá lớn nên không thể vẽ +@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: không phải ảnh PNG, JPEG, WebP, TIFF hay BMP +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ ảnh có kích thước {iw} × {ih} điểm ảnh, còn bảng vẽ ở chiều rộng đó có kích thước {rw} × {rh} điểm ảnh: tỉ lệ của chúng khác nhau +@msg `threshold` must be a number from 0 to 1 `threshold` phải là một số từ 0 đến 1 +@msg background {v}: a color such as "#808080" background {v}: màu như "#808080" +@msg comparing with an image file isn't available in the browser không thể so sánh với tệp ảnh trong trình duyệt @msg an image of {w} × {h} pixels is shown at reduced size ảnh {w} × {h} điểm ảnh được hiển thị ở kích thước thu nhỏ Basic Cơ bản Fill and Stroke Controls Điều khiển tô và nét @@ -5299,11 +5390,19 @@ theme Appearance Giao diện @msg too many library panels were opened in this session đã mở quá nhiều bảng thư viện trong phiên này @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Ảnh có kích thước {_1} × {_2} điểm ảnh, hơn {LARGE_TRACE_SIDE} ở một cạnh: ảnh vẫn được dò, nhưng chậm hơn. Một thiết lập sẵn có độ trung thực thấp hơn hoặc một bản sao nhỏ hơn của ảnh sẽ dò nhanh hơn. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Ảnh đã được dò ở {_1} × {_2} điểm ảnh, thu nhỏ từ {_3} × {_4} (tối đa {_5} megapixel). +@msg byColor must be true or false byColor phải là true hoặc false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor không thể tách các nhóm của Tự động nhóm: hãy dò mà không bật Tự động nhóm +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor không thể nhóm lại bản dò có hiệu ứng Pathfinder đang hoạt động: mở rộng mà không dùng byColor Snapping Hút Snap to Tangent Hút vào tiếp tuyến Snap to Perpendicular Hút vào đường vuông góc @msg unknown snap option `{key}` (use {options}) tùy chọn hút không hợp lệ `{key}` (dùng {options}) Gap Length: Độ dài khoảng hở: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. Chế độ Logo phẳng không dùng keepColors: chế độ này dò các màu được nêu trong logoColors hoặc các màu nó tìm thấy. +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). Không có gì được dò: không màu nào mà quá trình dò tìm thấy khớp với keepColors ({_1}). +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. Không có gì được dò: Bỏ qua màu bỏ đi mọi vùng của các màu mà keepColors liệt kê. +@msg Nothing was traced: the colors that match keepColors produce no paths. Không có gì được dò: các màu khớp với keepColors không tạo ra đường dẫn nào. +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: không màu nào mà quá trình dò tìm thấy khớp với {c}, nên không có gì của màu đó được dò. @msg `colorGroup` needs `library: "document"` `colorGroup` cần `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` cần `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` cần `mode: "color"` @@ -5418,11 +5517,14 @@ axis Y Axis Trục Y @msg Running “{name}”… Đang chạy “{name}”… @msg a live effect runs while the document is drawn, so it can't be `heavy` hiệu ứng trực tiếp chạy trong lúc tài liệu được vẽ nên không thể là `heavy` @msg an exporter always gets an export's budget: leave `budget` out plug-in xuất luôn nhận ngân sách của một lần xuất: hãy bỏ `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” yêu cầu ngân sách tính toán lớn (mỗi lần chạy tới {minutes} phút và gấp {times} lần số lệnh thông thường): hãy cài đặt với `allowHeavy: true` khi người dùng đồng ý +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” yêu cầu ngân sách tính toán lớn (mỗi lần chạy tới {minutes} phút và gấp {times} lần số lệnh thông thường): cần một người phê duyệt trong ứng dụng máy tính, hoặc truyền `allowHeavy: true` trong phiên khởi động bằng --allow-plugin-grants # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Cài đặt “{name}”, plug-in yêu cầu các quyền này? ID: {id} ID: {id} Version: {version} Phiên bản: {version} + Install “{name}” in place of the plug-in with the same ID? Cài đặt “{name}” thay cho plug-in có cùng ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Tệp này không phải là plug-in đã cài đặt trước đây với ID này. Tệp sẽ nhận cài đặt và bộ nhớ đệm được lưu cho ID đó. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” là mô-đun khác với plug-in có ID {_1} và sẽ nhận cài đặt và bộ nhớ đệm được lưu cho plug-in đó: hãy cài đặt với `replace: true` khi người dùng đồng ý File SHA-256: {hash} SHA-256 của tệp: {hash} Read the objects it is given Đọc các đối tượng được trao cho nó Change, add and delete objects in the document Thay đổi, thêm và xóa đối tượng trong tài liệu @@ -5430,21 +5532,43 @@ 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 Show its progress, see Cancel and write log lines Hiển thị tiến độ, nhận biết yêu cầu hủy và ghi các dòng nhật ký -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} quyền `progress` cần API plug-in cấp {PROGRESS_API}: hãy đặt `api` {PROGRESS_API} + Keep settings of its own Lưu cài đặt riêng + Keep a cache of its own, which you can clear Lưu bộ nhớ đệm riêng mà bạn có thể xóa + Clear Plug-in Cache Xóa bộ nhớ đệm của plug-in +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} quyền `{permission}` cần API plug-in cấp {PROGRESS_API}: hãy đặt `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} mô-đun nhập `env.{name}` với kiểu khác với kiểu của máy chủ: kiểu của máy chủ là {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` mô-đun nhập `env.{name}`, thứ mà plug-in chỉ được dùng ở API plug-in cấp {PROGRESS_API} (`api` {PROGRESS_API}) và với quyền `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network mô-đun nhập `{from}.{name}`, thứ mà máy chủ không cung cấp: plug-in chỉ được nhập `env.vc_progress`, `env.vc_cancelled` và `env.vc_log` (API plug-in cấp 2), không có WASI, tệp hay mạng +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` mô-đun nhập `env.{name}`, thứ mà plug-in chỉ được dùng ở API plug-in cấp {PROGRESS_API} (`api` {PROGRESS_API}) và với quyền `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network mô-đun nhập `{from}.{name}`, thứ mà máy chủ không cung cấp: plug-in chỉ được nhập các hàm `env.vc_*` mà docs/plugins.md liệt kê (API plug-in cấp 2), không có WASI, tệp hay mạ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` may only be true `paintOutput` chỉ có thể là true +@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}` @msg at most {MAX_PERMISSIONS} `permissions` tối đa {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` quyền `{shown}` không áp dụng cho plug-in loại `{_1}` @msg permission `{shown}` is listed twice quyền `{shown}` được liệt kê hai lần +@msg only a filter declares `inputs` chỉ bộ lọc mới khai báo `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` nêu một loại đối tượng không xác định `{shown}`: dùng type, image hoặc symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` liệt kê `{shown}` hai lần +@msg `inputs` needs `api` 2 or later `inputs` cần `api` 2 trở lên +@msg `inputs` needs the permission `doc.read` `inputs` cần quyền `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) ngân sách bộ nhớ (đối tượng {_1} cần {size} byte đầu vào, còn lại {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) ngân sách đối tượng (hơn {MAX_OBJECTS} đối tượng chỉ đọc) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it quyền `{shown}` được dành cho một API plug-in sau này; máy chủ này không cấp quyền đó @msg unknown permission `{shown}` quyền không xác định `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” yêu cầu các quyền {asked}: hãy cài đặt với `grant` liệt kê chúng khi người dùng đồng ý +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” yêu cầu các quyền {asked}: cần một người phê duyệt trong ứng dụng máy tính, hoặc truyền `grant` trong phiên khởi động bằng --allow-plugin-grants +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` và `allowHeavy` chỉ có hiệu lực trong phiên khởi động bằng --allow-plugin-grants: hãy phê duyệt trình cắm trong ứng dụng máy tính +@msg Only the person at the keyboard can approve installing a plug-in Chỉ người ngồi tại bàn phím mới có thể phê duyệt việc cài đặt trình cắm @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” chưa được cấp `{p}`, quyền mà đầu ra của nó cần: không có gì thay đổi +@msg a heavy filter runs in the background, so it can't declare `commands` bộ lọc nặng chạy ở chế độ nền nên không thể khai báo `commands` +@msg at most {MAX_COMMANDS} `commands` tối đa {MAX_COMMANDS} `commands` +@msg command {name} is declared twice lệnh {name} được khai báo hai lần +@msg command {name}: `label` must be 1-64 printable characters lệnh {name}: `label` phải có 1-64 ký tự in được +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter lệnh {name}: hãy dùng id gồm 1-{MAX_COMMAND_ID} ký tự từ a-z 0-9 _ và bắt đầu bằng một chữ cái +@msg command {name}: {e} lệnh {name}: {e} +@msg only a filter or a live effect declares `commands` chỉ bộ lọc hoặc hiệu ứng trực tiếp mới khai báo `commands` +@msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed mô-đun không có SHA-256 mà `hash` cho biết: không có gì được cài đặt # Actions panel: action sets (#3712). New Set… Bộ mới… @@ -5865,6 +5989,8 @@ breakText Glyphs Glyph File + Data Set Name Tệp + tên tập dữ liệu Data Set Name Tên tập dữ liệu @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` phải là `fileNumber`, `fileDataSetName` hoặc `dataSetName` + Each data set saves over the same file, which keeps only the last data set. Mỗi tập dữ liệu được lưu đè lên cùng một tệp, nên tệp chỉ giữ lại tập dữ liệu cuối cùng. +@msg save the document with Save As before Save and Close hãy lưu tài liệu bằng Lưu thành trước khi dùng Lưu và đóng @msg no free data set file name in destination folder Không có tên tệp tập dữ liệu còn trống trong thư mục đích # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -5985,9 +6111,20 @@ breakText Glyphs Glyph Reset Symbol Instance Đặt lại đối tượng ký hiệu @msg type must be static or dynamic, not {v} type phải là static hoặc dynamic, không phải {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` là ký hiệu tĩnh: trước tiên hãy đặt Kiểu ký hiệu thành Động +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Chỉ có thể thay đổi màu đặc của màu tô và nét của đường dẫn, đường dẫn phức hoặc chữ được chọn trong một đối tượng ký hiệu @msg path must be a list of child indices path phải là danh sách chỉ số con @msg no object at path {shown} in the symbol's art không có đối tượng tại đường dẫn {shown} trong hình của ký hiệu @msg give fill, stroke, opacity and/or blend hãy cung cấp fill, stroke, opacity và/hoặc blend +@msg {key} cannot be set on a guide không thể đặt {key} cho đường căn +@msg {key} cannot be set on a blend không thể đặt {key} cho hòa trộn +@msg {key} cannot be set on an envelope không thể đặt {key} cho envelope +@msg {key} cannot be set on a gradient mesh không thể đặt {key} cho lưới chuyển sắc +@msg {key} cannot be set on a Repeat không thể đặt {key} cho lặp lại +@msg {key} cannot be set on a placed document không thể đặt {key} cho tài liệu đã đặt +@msg {key} cannot be set on an Intertwine group không thể đặt {key} cho nhóm đan xen +@msg stroke cannot be set on a symbol instance không thể đặt stroke cho thực thể ký hiệu +@msg fill on a symbol instance must be a solid color or none fill của thực thể ký hiệu phải là màu đặc hoặc none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it không thể đặt {key} cho hình ảnh không có hiệu ứng Chuyển thành hình dạng hoặc Đường bao đối tượng @msg pass newName, registration, exportType, nineSlice and/or type hãy cung cấp newName, registration, exportType, nineSlice và/hoặc type Text Flow Luồng văn bản By Rows Theo hàng @@ -6017,6 +6154,7 @@ breakText Glyphs Glyph Create Swatch Legend Tạo chú giải mẫu màu Create Swatch Legend… Tạo chú giải mẫu màu… HEX HEX + Help Bar Thanh trợ giúp # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools Công cụ văn bản Text Tweaker… Tinh chỉnh văn bản… @@ -6082,3 +6220,44 @@ 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 plug-in of kind `{_2}`, not an importer `{_1}` là plug-in loại `{_2}`, không phải plug-in nhập +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` là plug-in loại `{_2}`, không phải plug-in xuất +@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` + +# Safe Mode + Safe Mode… Chế độ an toàn… + Safe Mode Chế độ an toàn + Help › Safe Mode… Trợ giúp › Chế độ an toàn… + Enable on Re-launch Bật ở lần khởi chạy tiếp theo + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Lần khởi chạy VectorCraft trước đã dừng khi đang tải plug-in {name}. Có thể plug-in đó không phải là nguyên nhân. + Disable and Continue Tắt và tiếp tục + Start Normally Khởi động bình thường + Start in Safe Mode Khởi động ở chế độ an toàn + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft đang ở chế độ an toàn: không plug-in nào được tải hay chạy cho đến khi ứng dụng khởi động bình thường. + Disabled plug-ins Plug-in đã tắt + No plug-ins are disabled. Không có plug-in nào bị tắt. +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Chế độ an toàn đang bật: plug-in không được tải hay chạy cho đến khi VectorCraft khởi động bình thường +@msg give `id` or `file` (see plugin.safeMode) cung cấp `id` hoặc `file` (xem plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) plug-in đó không bị tắt (xem plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch Không thể lưu các plug-in đã tắt cho lần khởi chạy tiếp theo +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Mở rộng diện mạo: không thể mở rộng hiệu ứng plug-in trong vùng chọn ở chế độ an toàn + +# Align to Pixel Grid + Align to Pixel Grid Căn theo lưới pixel +@msg only paths and compound paths can be aligned to the pixel grid chỉ đường dẫn và đường dẫn phức mới căn theo lưới pixel được +@msg side is left or right, not `{s}` side là left hoặc right, không phải `{s}` diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a2cf40630..82e3ed58a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -147,6 +147,10 @@ Anti-Alias 消除锯齿 Anti-alias Rasters 栅格消除锯齿 Anti-aliased Artwork 图稿抗锯齿 + Anti-aliasing Method 消除锯齿方法 +antiAlias Sharp 锐利 + Crisp 清晰 + Strong 强 Anti-aliasing: 消除锯齿: Appearance 外观 Appearance Attribute 外观属性 @@ -316,6 +320,13 @@ Apply Bullets 应用项目符号 Numbered List 编号列表 Apply Numbers 应用编号 + Bullets and Numbering… 项目符号和编号… + Numbers 编号 + Level: 级别: + Bullet Character: 项目符号字符: + Number Style: 编号样式: + Left Indent: 左缩进: + First Line Indent: 首行缩进: Convert to Text 转换为文本 Filled Dot 实心圆点 Dash 短划线 @@ -501,6 +512,11 @@ Color Table ({n} colors) 颜色表 ({n} 色) Color Theme 色彩主题 Color Themes 颜色主题 + Color Wheel 色轮 + Hue ring 色相环 + Saturation and brightness 饱和度和亮度 + Color field 颜色区域 + {name1} {value1}, {name2} {value2} {name1} {value1},{name2} {value2} Color Type: 颜色类型: Color: 颜色: Colorize 上色 @@ -1027,6 +1043,14 @@ Export As SVG… 导出为 SVG… Export Asset 导出资源 Export Assets 导出资源 + Export 3D Object 导出 3D 对象 + 3D Export Format 3D 导出格式 + Export 3D Assets 导出 3D 资源 +@msg {what} can't be exported in 3D: {e} 无法将 {what} 导出为 3D:{e} +@msg asset {id} isn't a 3D asset 资源 {id} 不是 3D 资源 +@msg asset {id} is a 3D asset: assets.export3D writes it 资源 {id} 是 3D 资源:由 assets.export3D 写入 +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) 还没有 3D 资源:请先收集带有 3D 效果的对象 (assets.add3D) +@msg select an object with a 3D effect (or give its ids) 请选择带有 3D 效果的对象 (或提供其 ID) Export As… 导出为… Export CSS 导出 CSS Export CSS… 导出 CSS… @@ -1058,6 +1082,17 @@ Exporting {} 正在导出 {} Export… 导出… Extrude & Bevel… 突出与斜角… + 3D (Classic) 3D (经典) + Extrude & Bevel (Classic)… 突出与斜角 (经典)… + Surface 表面 + Wireframe 线框 + No Shading 无底纹 + Plastic 塑料 + Highlight Intensity 高光强度 + Highlight Size 高光大小 + Blend Steps 混合步骤 + Shading Color 底纹颜色 + Draw Hidden Faces 绘制隐藏表面 Eyedropper 吸管 Sample Color from Screen 从屏幕取样颜色 This system has no screen color sampler 此系统没有屏幕取色工具 @@ -1114,6 +1149,7 @@ Stroke When Editable 可编辑时的描边 Fill Color 填色 Fill New Type Objects With Placeholder Text 用占位文本填充新文字对象 + Start Lists From Typed Markers 从键入的标记开始列表 Fill Rule 填充规则 Fill with Placeholder Text 用占位文本填充 Insert Inline Symbol 插入行内符号 @@ -1125,6 +1161,7 @@ Find Font… 查找字体… Find Next 查找下一个 Find Objects 查找对象 + Compare with Image 与图像比较 First Page 第一页 First-line Left Indent 首行左缩进 Fisheye… 鱼眼… @@ -2667,6 +2704,7 @@ digits Malayalam 马拉雅拉姆数字 Reset Transformation 重置变换 Replace With Font 替换为字体 Replace With Font From 替换字体来源 + Find In 查找范围 System 系统 Replaces the saved preset of that name. 替换同名已存预设。 Replace… 替换… @@ -2932,6 +2970,11 @@ digits Malayalam 马拉雅拉姆数字 Selected Swatches 所选色板 Selected Swatches Only 仅限选择的色板 Selection 所选对象 +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection 框选 + Touching 接触对象 + Fully Enclosed 完全包含 + Drag Direction 拖动方向 Selection & Anchor Display 选择和锚点显示 Selection Only 仅所选内容 Selection Tool 选择工具 @@ -3066,6 +3109,7 @@ digits Malayalam 马拉雅拉姆数字 Simulate Colored Paper 模拟有色纸张 Simulate Paper 模拟纸张 Double Column 双列 + Keep Tools Beside the Canvas 工具置于画布旁 Single Left Quotation Mark 左单引号 Single Quotes: 单引号: Single Right Quotation Mark 右单引号 @@ -3314,6 +3358,15 @@ digits Malayalam 马拉雅拉姆数字 Tate-chu-yoko 纵中横 Tate-chu-yoko Settings… 纵中横设置… Tate-chu-yoko Settings 纵中横设置 + Composite Fonts… 复合字体… + Composite Fonts 复合字体 + Export Composite Fonts 导出复合字体 + Import Composite Fonts 导入复合字体 + Delete Font 删除字体 + Kanji 汉字 + Kana 假名 + Numerals 数字 + Scale at Center 从中心缩放 Up/Down: 上/下: Left/Right: 左/右: Temperature 色温 @@ -3366,6 +3419,7 @@ digits Malayalam 马拉雅拉姆数字 Tile Type: 拼贴类型: tile {number} 拼贴 {number} Tiles are numbered across, then down; leave the range empty for every tile. 拼贴先由左至右再由上至下编号;范围留空表示全部拼贴。 + Copies per Sheet (as many as fit, in a grid) 每张纸印多份 (尽量排满,按网格排列) Tint 色调 Tip extends past the end point 箭头超出端点 Tip on the end point 箭头位于端点 @@ -3679,6 +3733,7 @@ digits Malayalam 马拉雅拉姆数字 Zoom to Selection 缩放到所选对象 Zoom Tool 缩放工具 Zoom with Mouse Wheel 使用鼠标滚轮缩放 + Scroll Sideways with Cmd/Ctrl + Wheel 使用 Cmd/Ctrl + 滚轮水平滚动 Scrub Numeric Fields by Dragging 拖动以调整数值字段 @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {folder} 中有 {n} 个文件{note}。要显示打包文件夹吗? @plural {n} linked file not found|{n} linked files not found 找不到 {n} 个链接文件 @@ -3850,14 +3905,14 @@ imageTrace Outline 轮廓 @msg The last start of VectorCraft ended on {name} before its window appeared, so it started on {summary}. To try that graphics processor again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set VectorCraft 上次启动时在其窗口出现之前于 {name} 上终止,因此本次在 {summary} 上启动。要再次尝试该图形处理器,请更改“首选项 › 性能 › 图形处理器”,或在设置 WGPU_BACKEND 后启动 @msg The last start of VectorCraft ended while it listed the graphics processors, so it started on {summary} with OpenGL alone. To try the other graphics backends again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set VectorCraft 上次启动时在列出图形处理器期间终止,因此本次仅使用 OpenGL 在 {summary} 上启动。要再次尝试其他图形后端,请更改“首选项 › 性能 › 图形处理器”,或在设置 WGPU_BACKEND 后启动 @msg not a DXF drawing 不是 DXF 图纸 -@msg scale must be a number from {_1} to {_2}, not {s} 比例必须是 {_1} 到 {_2} 之间的数字,而不是 {s} +@msg scale must be a number from {_1} to {_2}, not {s} scale 必须是 {_1} 到 {_2} 之间的数字,而不是 {s} @msg the artboard to fit must be a positive size up to {_1} pt 要适合的画板尺寸必须为正数,且不超过 {_1} pt @msg the drawing has no layout `{_1}` (it has {_2}) 图纸中没有布局 `{_1}` (它包含 {_2}) @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that 这是 DWG 图纸,无法读取:请在 CAD 应用中将其保存为 ASCII DXF,然后打开该文件 @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard 按此比例,图纸为 {w} × {h} pt,超出画布的容量:请选择较小的比例,或使其适合画板 @msg the scale is not a usable number 比例不是可用的数字 @msg the drawing is too large to open 图纸过大,无法打开 -@msg scale must be a number from {_1} to {_2}, not {_3} 比例必须是 {_1} 到 {_2} 之间的数字,而不是 {_3} +@msg scale must be a number from {_1} to {_2}, not {_3} scale 必须是 {_1} 到 {_2} 之间的数字,而不是 {_3} @msg an image has no pixels and was left out 已略去没有像素的图像 @msg object {0} cannot have children 对象 {0} 不能包含子对象 @msg range `{s}` is empty 范围 `{s}` 为空 @@ -3960,7 +4015,7 @@ imageTrace Outline 轮廓 @msg {name}: no local header {name}:没有本地文件头 @msg Flatten Transparency: select objects to flatten 拼合透明度:请选择要拼合的对象 @msg Flatten Transparency: {e} 拼合透明度:{e} -@msg balance must be between 0 and 100 平衡必须介于 0 到 100 之间 +@msg balance must be between 0 and 100 balance 必须介于 0 到 100 之间 @msg resolutions must be between 1 and 2400 ppi 分辨率必须介于 1 到 2400 ppi 之间 @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) 未知预设 `{name}` (high、medium、low 或已保存的预设:请参阅 flattener.presets.list) @msg `at` must be [x, y] `at` 必须是 [x, y] @@ -3976,6 +4031,7 @@ imageTrace Outline 轮廓 @msg no point {i} (the gradient has {_1}) 没有点 {i} (该渐变有 {_1} 个) @msg no segment {segment} on line {line} 线 {line} 上没有线段 {segment} @msg unknown freeform mode `{m}` (points, lines) 未知的任意形状模式 `{m}` (points、lines) +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` 必须是 "none"、"sharp"、"crisp" 或 "strong" @msg `aspect` must be a number (%) `aspect` 必须是数字 (%) @msg `gradient` must be an object `gradient` 必须是对象 @msg `groupColors` must be an array `groupColors` 必须是数组 @@ -4126,7 +4182,7 @@ imageTrace Outline 轮廓 @msg no artboard {i} (0-based; the document has {_1}) 没有画板 {i} (从 0 开始计数;文档有 {_1} 个) @msg no selected slice in the image: select slices, or save all of them 图像中没有选中的切片:请选择切片,或全部保存 @msg nothing to save: the document has no visible art 没有可保存的内容:文档中没有可见的图稿 -@msg percent must be above 0 and at most 6400, not {p} 百分比必须大于 0 且不超过 6400,而不是 {p} +@msg percent must be above 0 and at most 6400, not {p} percent 必须大于 0 且不超过 6400,而不是 {p} @msg unknown preset `{name}` (see webExport.presets.list) 未知预设 `{name}` (请参阅 webExport.presets.list) @msg width and height are at least 1 pixel 宽度和高度至少为 1 像素 @msg `sampleSize` must be 1, 3 or 5 `sampleSize` 必须是 1、3 或 5 @@ -4168,9 +4224,13 @@ imageTrace Outline 轮廓 @msg an image VectorCraft can't read was left out 已略去 VectorCraft 无法读取的图像 @msg graphs come in as plain art, without their data or settings 图表作为普通图稿导入,不保留其数据和设置 @msg an image whose data is cut short was left out 已略去数据不完整的图像 +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers 为保留图层,{_1} × {_2} 像素的嵌入图像已缩小为 {_3} × {_4} 像素 @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 文件中未定义渐变“{name}”:已改用纯色代替 @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) VectorCraft 尚无法读取的画笔未加入“画笔”面板 ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 带有多个填色或描边、效果或画笔的对象已按其绘制外观导入 ({_1}):其外观尚不可编辑 +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 带有多个填色或描边、可变宽度描边、效果或画笔的对象已按其绘制外观导入 ({_1}):其外观尚不可编辑 +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding 名称 `{name}` 不是 UTF-8,已按 {label} 读取:如果显示不正确,请使用其他 legacyNameEncoding 打开文件 +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding 名称 `{name}` 不是 UTF-8,且部分内容在 {label} 中也无效:请使用其他 legacyNameEncoding 打开文件 +@msg more names that aren't UTF-8 were read as {label} ({count}) 其他非 UTF-8 名称已按 {label} 读取 ({count}) @msg patterns nested too deeply are filled with mid-grey 嵌套过深的图案以中灰色填充 @msg shadings of an unknown type were left out 已略去未知类型的着色 @msg shadings whose functions are of an unknown type are filled with their middle colour 函数类型未知的着色以其中间颜色填充 @@ -4283,7 +4343,7 @@ imageTrace Outline 轮廓 @msg is not an image VectorCraft reads 不是 VectorCraft 可读取的图像 @msg not found 未找到 @msg SVG parse error: {0} SVG 解析错误:{0} -@msg decimals must be {_1}–{_2} (got {_3}) 小数位数必须为 {_1}–{_2} (实际为 {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals 必须为 {_1}–{_2} (实际为 {_3}) @msg Exporting {_1} 正在导出 {_1} @msg Packaging {_1} 正在打包 {_1} @msg Placing {_1} 正在置入 {_1} @@ -4356,6 +4416,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 Preview in Browser needs the desktop app (the web downloads files instead) “在浏览器中预览”需要桌面应用 (网页版会改为下载文件) @msg no file system here 此处没有文件系统 @msg nothing to preview 没有可预览的内容 @@ -4439,6 +4506,8 @@ imageTrace Outline 轮廓 @msg no such action set 没有此动作组 @msg the action sets changed; nothing was deleted 动作组已更改;未删除任何内容 @msg no action set to save 没有要存储的动作组 +@msg the action set file can't be read in full 无法完整读取动作组文件 +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 已载入“{name}”:已略过 {n} 个步骤 @msg not an action set file 不是动作组文件 @msg the action set file is larger than 8 MiB 动作组文件大于 8 MiB @msg the action set file holds more than 1,000 actions 动作组文件包含超过 1,000 个动作 @@ -4447,7 +4516,7 @@ imageTrace Outline 轮廓 @msg choose a folder to export to 请选择要导出到的文件夹 @msg the selection has no URL (or they differ) 所选对象没有 URL (或其 URL 各不相同) @msg select some type first 请先选择一些文字 -@msg kerning must be a number, "auto", or "optical" 字偶距必须是数字、“auto”或“optical” +@msg kerning must be a number, "auto", or "optical" kerning 必须是数字、“auto”或“optical” @msg the selection has no gradient fill 所选对象没有渐变填色 @msg the selection has no gradient stroke 所选对象没有渐变描边 @msg the selection has no pattern fill 所选对象没有图案填色 @@ -4569,6 +4638,7 @@ imageTrace Outline 轮廓 @msg object ids are non-negative integers, not {v} 对象 id 是非负整数,而不是 {v} @msg {key} must be an array of non-negative integer object ids {key} 必须是由非负整数对象 id 组成的数组 @msg invalid object id {value}: expected a non-negative integer 无效的对象 id {value}:应为非负整数 +@msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` 中索引 {index} 处的项为 {value},不是非负整数对象 id @msg no such object id {value} 没有 id 为 {value} 的对象 @msg id {value} is a layer, not an object id {value} 是图层,不是对象 @msg id {value} is a layer, not an object; move layers with layer.move id {value} 是图层,不是对象;请使用 layer.move 移动图层 @@ -4595,6 +4665,9 @@ imageTrace Outline 轮廓 @msg the Pixar file's image data is damaged Pixar 文件的图像数据已损坏 @msg the JPEG 2000 file can't be read 无法读取 JPEG 2000 文件 @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 文件包含不受支持的色彩空间或 JPX 内容 +@msg the HEIC or HEIF file can't be read 无法读取 HEIC 或 HEIF 文件 +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC 或 HEIF 文件包含无法读取的图像类型 +@msg HEIC and HEIF images can't be opened in this build 此版本无法打开 HEIC 和 HEIF 图像 @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 图像过大,无法读取 ({_1} × {_2} 像素) @msg zipped 32-bit PSD image data with prediction can't be read 无法读取带预测的 ZIP 压缩 32 位 PSD 图像数据 @msg {depth}-bit PSD files in this colour mode can't be read 无法读取此颜色模式下的 {depth} 位 PSD 文件 @@ -4609,7 +4682,7 @@ imageTrace Outline 轮廓 @msg before must name a panel before 必须指定面板 @msg docking action contains an unknown panel or an excessive split path 停靠操作包含未知面板或过长的拆分路径 @msg group must be true or false group 必须是 true 或 false -@msg panel is not in a dock group 面板不在停放组中 +@msg panel is not in a dock group panel 不在停放组中 @msg only a dock group above or below others collapses, not one beside another 只有位于其他组上方或下方的停放组可以折叠,并排的不行 @msg icon must be true or false icon 必须为 true 或 false @msg icon only docks a panel in the icon column, with no anchor icon 只会把面板停放到图标栏中,不能带 anchor @@ -4703,6 +4776,7 @@ imageTrace Outline 轮廓 Report copied 已复制报告 Line {line} 第 {line} 行 Line {line}: {name}: {status} 第 {line} 行:{name}:{status} + Read Action Set File 读取动作组文件 Read Shortcut Set File 读取快捷键组文件 Import Matches 导入对应 Remember Import Matches 记住导入对应 @@ -4989,6 +5063,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 @@ -5068,7 +5146,7 @@ axis Both 两者 Dock to Bottom 停放到底部 Dock Control Bar 停放控制栏 Drag to float the toolbar 拖动以浮动工具栏 - Drag to the window's left edge to dock 拖到窗口左边缘以停放 + Drag to the window's left or right edge to dock 拖到窗口左边缘或右边缘以停放 Float Tool Group 浮动工具组 # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. @@ -5201,6 +5279,9 @@ effect Horizontal 水平 effect Vertical 垂直 Edge 边缘 System Title Bar 系统标题栏 + VectorCraft Title Bar on Every Desktop 在所有桌面上使用 VectorCraft 标题栏 + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. 设置了环境变量 VECTORCRAFT_TITLEBAR 时,由它选择标题栏。 + System Title Bar takes precedence over this option while it is on. 开启系统标题栏时,它优先于此选项。 Value Axis 数值轴 On Left Side 左侧 On Right Side 右侧 @@ -5386,6 +5467,15 @@ 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 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 {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}:不是 PNG、JPEG、WebP、TIFF 或 BMP 图像 +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ 图像为 {iw} × {ih} 像素,而画板在该宽度下为 {rw} × {rh} 像素:两者的比例不同 +@msg `threshold` must be a number from 0 to 1 `threshold` 必须是 0 到 1 之间的数字 +@msg background {v}: a color such as "#808080" background {v}:颜色,例如 "#808080" +@msg comparing with an image file isn't available in the browser 浏览器中无法与图像文件进行比较 @msg an image of {w} × {h} pixels is shown at reduced size {w} × {h} 像素的图像以缩小的尺寸显示 Fill and Stroke Controls 填色和描边控件 Coloring Controls 颜色控件 @@ -5536,11 +5626,19 @@ lighting Light 光源 @msg too many library panels were opened in this session 本次会话中打开的库面板过多 @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 图像为 {_1} × {_2} 像素,某一边超过 {LARGE_TRACE_SIDE}:会进行描摹,但速度较慢。使用保真度较低的预设或图像的较小副本会更快。 @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 图像已按 {_1} × {_2} 像素描摹,由 {_3} × {_4} 缩小而来(最多 {_5} 百万像素)。 +@msg byColor must be true or false byColor 必须是 true 或 false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor 无法拆分自动编组生成的编组:请在不使用自动编组的情况下描摹 +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor 无法重新编组带有实时 Pathfinder 效果的描摹:请不使用 byColor 进行扩展 Snapping 对齐 Snap to Tangent 对齐切线 Snap to Perpendicular 对齐垂线 @msg unknown snap option `{key}` (use {options}) 未知的对齐选项 `{key}`(可用:{options}) Gap Length: 间隙长度: +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. “扁平徽标”模式不使用 keepColors:它描摹 logoColors 中指定的颜色,或它找到的颜色。 +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). 未描摹任何内容:描摹找到的颜色中没有一种与 keepColors ({_1}) 匹配。 +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. 未描摹任何内容:“忽略颜色”排除了 keepColors 所列颜色的所有区域。 +@msg Nothing was traced: the colors that match keepColors produce no paths. 未描摹任何内容:与 keepColors 匹配的颜色未生成任何路径。 +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors:描摹找到的颜色中没有一种与 {c} 匹配,因此该颜色未被描摹。 @msg `colorGroup` needs `library: "document"` `colorGroup` 需要 `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` 需要 `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` 需要 `mode: "color"` @@ -5659,11 +5757,14 @@ axis Y Axis Y 轴 @msg Running “{name}”… 正在运行“{name}”… @msg a live effect runs while the document is drawn, so it can't be `heavy` 实时效果在绘制文档时运行,因此不能为 `heavy` @msg an exporter always gets an export's budget: leave `budget` out 导出增效工具始终使用导出的预算:请省略 `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}”需要大量计算预算(每次运行最多 {minutes} 分钟,指令数为通常的 {times} 倍):请在用户同意后使用 `allowHeavy: true` 安装 +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}”需要大量计算预算(每次运行最多 {minutes} 分钟,指令数为通常的 {times} 倍):需要有人在桌面应用中批准,或在以 --allow-plugin-grants 启动的会话中传入 `allowHeavy: true` # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? 要安装需要这些权限的“{name}”吗? ID: {id} ID:{id} Version: {version} 版本:{version} + Install “{name}” in place of the plug-in with the same ID? 要安装“{name}”来替换具有相同 ID 的插件吗? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. 此文件不是之前以此 ID 安装的插件。它将获得为该 ID 保存的设置和缓存。 +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}”与 ID 为 {_1} 的插件是不同的模块,并会获得为其保存的设置和缓存:请在用户同意后使用 `replace: true` 安装 File SHA-256: {hash} 文件 SHA-256:{hash} Read the objects it is given 读取交给它的对象 Change, add and delete objects in the document 更改、添加和删除文档中的对象 @@ -5671,21 +5772,43 @@ 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 将文档写为其自有格式的文件 Show its progress, see Cancel and write log lines 显示进度、检测取消请求并写入日志行 -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 权限 `progress` 需要增效工具 API 级别 {PROGRESS_API}:请设置 `api` {PROGRESS_API} + Keep settings of its own 保存自己的设置 + Keep a cache of its own, which you can clear 保存自己的缓存,您可以清除它 + Clear Plug-in Cache 清除增效工具缓存 +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 权限 `{permission}` 需要增效工具 API 级别 {PROGRESS_API}:请设置 `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} 模块以与宿主不同的类型导入 `env.{name}`:宿主的类型为 {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` 模块导入了 `env.{name}`,增效工具只有在增效工具 API 级别 {PROGRESS_API}(`api` {PROGRESS_API})且具有权限 `progress` 时才能使用它 -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network 模块导入了宿主不提供的 `{from}.{name}`:增效工具只能导入 `env.vc_progress`、`env.vc_cancelled` 和 `env.vc_log`(增效工具 API 级别 2),不能使用 WASI、文件或网络 +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` 模块导入了 `env.{name}`,增效工具只有在增效工具 API 级别 {PROGRESS_API}(`api` {PROGRESS_API})且具有权限 `{permission}` 时才能使用它 +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network 模块导入了宿主不提供的 `{from}.{name}`:增效工具只能导入 docs/plugins.md 列出的 `env.vc_*` 函数(增效工具 API 级别 2),不能使用 WASI、文件或网络 @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` 为 {_1};此主机支持的增效工具 API 级别为 1 到 {API_LEVEL} +@msg `paintOutput` may only be true `paintOutput` 只能为 true +@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}` @msg at most {MAX_PERMISSIONS} `permissions` 最多 {MAX_PERMISSIONS} 个 `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 权限 `{shown}` 不适用于 `{_1}` 类型的增效工具 @msg permission `{shown}` is listed twice 权限 `{shown}` 列出了两次 +@msg only a filter declares `inputs` 只有滤镜才能声明 `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` 列出了未知的对象类型 `{shown}`:请使用 type、image 或 symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` 列出了两次 `{shown}` +@msg `inputs` needs `api` 2 or later `inputs` 需要 `api` 2 或更高 +@msg `inputs` needs the permission `doc.read` `inputs` 需要权限 `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) 内存预算(对象 {_1} 需要 {size} 字节的输入,剩余 {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) 对象预算(超过 {MAX_OBJECTS} 个只读对象) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 权限 `{shown}` 保留给以后的增效工具 API;此主机不授予该权限 @msg unknown permission `{shown}` 未知权限 `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”需要权限 {asked}:请在用户同意后使用列出这些权限的 `grant` 安装 +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}”需要权限 {asked}:需要有人在桌面应用中批准,或在以 --allow-plugin-grants 启动的会话中传入 `grant` +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` 和 `allowHeavy` 仅在以 --allow-plugin-grants 启动的会话中生效:请在桌面应用中批准此插件 +@msg Only the person at the keyboard can approve installing a plug-in 只有键盘前的人才能批准安装插件 @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}”未获授予其输出所需的 `{p}`:未作任何更改 +@msg a heavy filter runs in the background, so it can't declare `commands` 重型滤镜在后台运行,因此不能声明 `commands` +@msg at most {MAX_COMMANDS} `commands` 最多 {MAX_COMMANDS} 个 `commands` +@msg command {name} is declared twice 命令 {name} 声明了两次 +@msg command {name}: `label` must be 1-64 printable characters 命令 {name}:`label` 必须是 1-64 个可打印字符 +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter 命令 {name}:请使用由 a-z 0-9 _ 组成、以字母开头的 1-{MAX_COMMAND_ID} 个字符的 ID +@msg command {name}: {e} 命令 {name}:{e} +@msg only a filter or a live effect declares `commands` 只有滤镜或实时效果才能声明 `commands` +@msg “{name}” › {label}: {e} “{name}” › {label}:{e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模块的 SHA-256 与 `hash` 给出的不符:未安装任何内容 # Actions panel: action sets (#3712). New Set… 新建组… @@ -6106,6 +6229,8 @@ breakText Glyphs 字形 File + Data Set Name 文件 + 数据集名称 Data Set Name 数据集名称 @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` 必须是 `fileNumber`、`fileDataSetName` 或 `dataSetName` + Each data set saves over the same file, which keeps only the last data set. 每个数据集都会存储到同一个文件并覆盖它,该文件只保留最后一个数据集。 +@msg save the document with Save As before Save and Close 请先用“另存为”保存文档,再使用“保存并关闭” @msg no free data set file name in destination folder 目标文件夹中没有可用的数据集文件名 # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6225,9 +6350,20 @@ breakText Glyphs 字形 Reset Symbol Instance 重置符号实例 @msg type must be static or dynamic, not {v} type 必须是 static 或 dynamic,而不是 {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` 是静态符号:请先将其符号类型设为动态 +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change 在符号实例中选中的路径、复合路径或文字只能更改填色和描边的纯色 @msg path must be a list of child indices path 必须是子项索引列表 @msg no object at path {shown} in the symbol's art 符号图稿中路径 {shown} 处没有对象 @msg give fill, stroke, opacity and/or blend 请提供 fill、stroke、opacity 和/或 blend +@msg {key} cannot be set on a guide 无法为参考线设置 {key} +@msg {key} cannot be set on a blend 无法为混合设置 {key} +@msg {key} cannot be set on an envelope 无法为封套设置 {key} +@msg {key} cannot be set on a gradient mesh 无法为渐变网格设置 {key} +@msg {key} cannot be set on a Repeat 无法为重复设置 {key} +@msg {key} cannot be set on a placed document 无法为置入的文档设置 {key} +@msg {key} cannot be set on an Intertwine group 无法为交织编组设置 {key} +@msg stroke cannot be set on a symbol instance 无法为符号实例设置 stroke +@msg fill on a symbol instance must be a solid color or none 符号实例的 fill 必须是纯色或 none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it 无法为没有“转换为形状”或“轮廓化对象”效果的图像设置 {key} @msg pass newName, registration, exportType, nineSlice and/or type 请提供 newName、registration、exportType、nineSlice 和/或 type Text Flow 文本流向 By Rows 按行 @@ -6257,6 +6393,7 @@ breakText Glyphs 字形 Create Swatch Legend 创建色板图例 Create Swatch Legend… 创建色板图例… HEX HEX + Help Bar 帮助栏 # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools 文字工具 Text Tweaker… 文字微调… @@ -6322,3 +6459,44 @@ 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 plug-in of kind `{_2}`, not an importer `{_1}` 是 `{_2}` 类型的增效工具,不是导入增效工具 +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_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` + +# Safe Mode + Safe Mode… 安全模式… + Safe Mode 安全模式 + Help › Safe Mode… 帮助 › 安全模式… + Enable on Re-launch 下次启动时启用 + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. VectorCraft 上次启动时在载入增效工具 {name} 期间停止。该增效工具不一定是原因。 + Disable and Continue 停用并继续 + Start Normally 正常启动 + Start in Safe Mode 以安全模式启动 + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft 处于安全模式:在正常启动之前,不会载入或运行任何增效工具。 + Disabled plug-ins 已停用的增效工具 + No plug-ins are disabled. 没有停用的增效工具。 +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally 安全模式已开启:在 VectorCraft 正常启动之前,增效工具不会载入或运行 +@msg give `id` or `file` (see plugin.safeMode) 请提供 `id` 或 `file` (请参阅 plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) 该增效工具未停用 (请参阅 plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch 无法为下次启动保存已停用的增效工具 +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode 扩展外观:在安全模式下无法扩展所选对象中的增效工具效果 + +# Align to Pixel Grid + Align to Pixel Grid 对齐像素网格 +@msg only paths and compound paths can be aligned to the pixel grid 只有路径和复合路径可以对齐像素网格 +@msg side is left or right, not `{s}` side 应为 left 或 right,而不是 `{s}` diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9440e225f..e65d4a84f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -156,6 +156,10 @@ Anti-Alias 消除鋸齒 Anti-alias Rasters 點陣影像消除鋸齒 Anti-aliased Artwork 消除鋸齒的圖稿 + Anti-aliasing Method 消除鋸齒方法 +antiAlias Sharp 銳利 + Crisp 清晰 + Strong 強 Anti-aliasing: 消除鋸齒: Any Kind 所有類型 Font Technology 字體技術 @@ -329,6 +333,13 @@ font-technology Variable 可變 Apply Bullets 套用項目符號 Numbered List 編號清單 Apply Numbers 套用編號 + Bullets and Numbering… 項目符號與編號… + Numbers 編號 + Level: 層級: + Bullet Character: 項目符號字元: + Number Style: 編號樣式: + Left Indent: 左縮排: + First Line Indent: 首行縮排: Convert to Text 轉換為文字 Filled Dot 實心圓點 Dash 短破折號 @@ -517,6 +528,11 @@ font-technology Variable 可變 Color Table ({n} colors) 顏色表 ({n} 色) Color Theme 色彩主題 Color Themes 色彩主題 + Color Wheel 色輪 + Hue ring 色相環 + Saturation and brightness 飽和度和亮度 + Color field 顏色區域 + {name1} {value1}, {name2} {value2} {name1} {value1},{name2} {value2} Color Type: 顏色類型: Color: 顏色: Colorize 上色 @@ -1051,6 +1067,14 @@ font-technology Variable 可變 Export As SVG… 匯出為 SVG… Export Asset 匯出資產 Export Assets 匯出資產 + Export 3D Object 匯出 3D 物件 + 3D Export Format 3D 匯出格式 + Export 3D Assets 匯出 3D 資產 +@msg {what} can't be exported in 3D: {e} 無法將 {what} 匯出為 3D:{e} +@msg asset {id} isn't a 3D asset 資產 {id} 不是 3D 資產 +@msg asset {id} is a 3D asset: assets.export3D writes it 資產 {id} 是 3D 資產:由 assets.export3D 寫入 +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) 尚無 3D 資產:請先收集具有 3D 效果的物件 (assets.add3D) +@msg select an object with a 3D effect (or give its ids) 請選取具有 3D 效果的物件 (或提供其 ID) Export As… 匯出為… Export CSS 匯出 CSS Export CSS… 匯出 CSS… @@ -1082,6 +1106,17 @@ font-technology Variable 可變 Exporting {} 正在匯出 {} Export… 匯出… Extrude & Bevel… 突出與斜角… + 3D (Classic) 3D (經典) + Extrude & Bevel (Classic)… 突出與斜角 (經典)… + Surface 表面 + Wireframe 線框 + No Shading 無網底 + Plastic 塑膠 + Highlight Intensity 亮部強度 + Highlight Size 亮部大小 + Blend Steps 漸變階數 + Shading Color 網底顏色 + Draw Hidden Faces 繪製隱藏表面 Eyedropper 滴管 Sample Color from Screen 從螢幕取樣顏色 This system has no screen color sampler 此系統沒有螢幕取色工具 @@ -1139,6 +1174,7 @@ font-technology Variable 可變 Highlight: 強調顯示: Stroke When Editable 可編輯時的筆畫 Fill New Type Objects With Placeholder Text 以預留位置文字填滿新文字物件 + Start Lists From Typed Markers 從輸入的標記開始清單 Fill Rule 填色規則 Fill with Placeholder Text 以預留位置文字填滿 Insert Inline Symbol 插入行內符號 @@ -1150,6 +1186,7 @@ font-technology Variable 可變 Find Font… 尋找字體… Find Next 尋找下一個 Find Objects 尋找物件 + Compare with Image 與影像比較 First Page 第一頁 First-line Left Indent 首行左縮排 Fisheye… 魚眼… @@ -1711,14 +1748,14 @@ file-filter Templates 範本 @msg The last start of VectorCraft ended on {name} before its window appeared, so it started on {summary}. To try that graphics processor again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set VectorCraft 上次啟動時在 {name} 上於視窗出現前結束,因此這次在 {summary} 上啟動。若要再次嘗試該圖形處理器,請變更「偏好設定 › 效能 › 圖形處理器」,或設定 WGPU_BACKEND 後再啟動 @msg The last start of VectorCraft ended while it listed the graphics processors, so it started on {summary} with OpenGL alone. To try the other graphics backends again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set VectorCraft 上次啟動時在列出圖形處理器期間結束,因此這次只以 OpenGL 在 {summary} 上啟動。若要再次嘗試其他圖形後端,請變更「偏好設定 › 效能 › 圖形處理器」,或設定 WGPU_BACKEND 後再啟動 @msg not a DXF drawing 不是 DXF 圖面 -@msg scale must be a number from {_1} to {_2}, not {s} 比例必須是 {_1} 到 {_2} 之間的數字,而非 {s} +@msg scale must be a number from {_1} to {_2}, not {s} scale 必須是 {_1} 到 {_2} 之間的數字,而非 {s} @msg the artboard to fit must be a positive size up to {_1} pt 要符合的工作區域尺寸必須為正數,且不超過 {_1} pt @msg the drawing has no layout `{_1}` (it has {_2}) 圖面中沒有配置 `{_1}` (它有 {_2}) @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that 這是 DWG 圖面,無法讀取:請在 CAD 應用程式中將其儲存為 ASCII DXF,再開啟該檔案 @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard 以此比例,圖面為 {w} × {h} pt,超過畫布所能容納:請選擇較小的比例,或使其符合工作區域 @msg the scale is not a usable number 比例不是可用的數字 @msg the drawing is too large to open 圖面過大,無法開啟 -@msg scale must be a number from {_1} to {_2}, not {_3} 比例必須是 {_1} 到 {_2} 之間的數字,而非 {_3} +@msg scale must be a number from {_1} to {_2}, not {_3} scale 必須是 {_1} 到 {_2} 之間的數字,而非 {_3} @msg an image has no pixels and was left out 已略過沒有像素的影像 @msg object {0} cannot have children 物件 {0} 不能有子物件 @msg range `{s}` is empty 範圍 `{s}` 是空的 @@ -1821,7 +1858,7 @@ file-filter Templates 範本 @msg {name}: no local header {name}:沒有區域檔頭 @msg Flatten Transparency: select objects to flatten 透明度平面化:請選取要平面化的物件 @msg Flatten Transparency: {e} 透明度平面化:{e} -@msg balance must be between 0 and 100 平衡必須介於 0 到 100 之間 +@msg balance must be between 0 and 100 balance 必須介於 0 到 100 之間 @msg resolutions must be between 1 and 2400 ppi 解析度必須介於 1 到 2400 ppi 之間 @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) 未知的預設集 `{name}` (high、medium、low 或已儲存的預設集:請參閱 flattener.presets.list) @msg `at` must be [x, y] `at` 必須是 [x, y] @@ -1835,6 +1872,7 @@ file-filter Templates 範本 @msg no point {i} (the gradient has {_1}) 沒有點 {i} (此漸層有 {_1} 個) @msg no segment {segment} on line {line} 線條 {line} 上沒有線段 {segment} @msg unknown freeform mode `{m}` (points, lines) 未知的任意形狀模式 `{m}` (points、lines) +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` 必須是 "none"、"sharp"、"crisp" 或 "strong" @msg `aspect` must be a number (%) `aspect` 必須是數字 (%) @msg `gradient` must be an object `gradient` 必須是物件 @msg `groupColors` must be an array `groupColors` 必須是陣列 @@ -1985,7 +2023,7 @@ file-filter Templates 範本 @msg no artboard {i} (0-based; the document has {_1}) 沒有工作區域 {i} (從 0 起算;文件有 {_1} 個) @msg no selected slice in the image: select slices, or save all of them 影像中沒有已選取的切片:請選取切片,或全部儲存 @msg nothing to save: the document has no visible art 沒有可儲存的內容:文件中沒有可見的圖稿 -@msg percent must be above 0 and at most 6400, not {p} 百分比必須大於 0 且不超過 6400,而非 {p} +@msg percent must be above 0 and at most 6400, not {p} percent 必須大於 0 且不超過 6400,而非 {p} @msg unknown preset `{name}` (see webExport.presets.list) 未知的預設集 `{name}` (請參閱 webExport.presets.list) @msg width and height are at least 1 pixel 寬度和高度至少為 1 像素 @msg `sampleSize` must be 1, 3 or 5 `sampleSize` 必須是 1、3 或 5 @@ -2027,9 +2065,13 @@ file-filter Templates 範本 @msg an image VectorCraft can't read was left out 已略過 VectorCraft 無法讀取的影像 @msg graphs come in as plain art, without their data or settings 圖表以一般圖稿匯入,不保留其資料與設定 @msg an image whose data is cut short was left out 已略過資料不完整的影像 +@msg an embedded image of {_1} × {_2} pixels was reduced to {_3} × {_4} pixels to keep the layers 為保留圖層,{_1} × {_2} 像素的內嵌影像已縮小為 {_3} × {_4} 像素 @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 檔案中未定義漸層「{name}」:已改用單色替代 @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) VectorCraft 尚無法讀取的筆刷未加入「筆刷」面板 ({_1}) -@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 具有多個填色或筆畫、效果或筆刷的物件已依其繪製外觀匯入 ({_1}):其外觀目前尚無法編輯 +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 具有多個填色或筆畫、可變寬度筆畫、效果或筆刷的物件已依其繪製外觀匯入 ({_1}):其外觀目前尚無法編輯 +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding 名稱 `{name}` 不是 UTF-8,已依 {label} 讀取:若顯示不正確,請以其他 legacyNameEncoding 開啟檔案 +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding 名稱 `{name}` 不是 UTF-8,且部分內容在 {label} 中也無效:請以其他 legacyNameEncoding 開啟檔案 +@msg more names that aren't UTF-8 were read as {label} ({count}) 其他非 UTF-8 名稱已依 {label} 讀取 ({count}) @msg patterns nested too deeply are filled with mid-grey 巢狀層級過深的圖樣以中灰色填滿 @msg shadings of an unknown type were left out 已略過類型未知的著色 @msg shadings whose functions are of an unknown type are filled with their middle colour 函數類型未知的著色以其中間顏色填滿 @@ -2145,7 +2187,7 @@ file-filter Templates 範本 @msg is not an image VectorCraft reads 不是 VectorCraft 可讀取的影像 @msg not found 找不到 @msg SVG parse error: {0} SVG 剖析錯誤:{0} -@msg decimals must be {_1}–{_2} (got {_3}) 小數位數必須為 {_1}–{_2} (收到 {_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals 必須為 {_1}–{_2} (收到 {_3}) @msg Exporting {_1} 正在匯出 {_1} @msg Packaging {_1} 正在封裝 {_1} @msg Placing {_1} 正在置入 {_1} @@ -2218,6 +2260,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 Preview in Browser needs the desktop app (the web downloads files instead) 「在瀏覽器中預覽」需要桌面應用程式 (網頁版會改為下載檔案) @msg no file system here 此處沒有檔案系統 @msg nothing to preview 沒有可預覽的內容 @@ -2301,6 +2350,8 @@ file-filter Templates 範本 @msg no such action set 沒有這個動作組 @msg the action sets changed; nothing was deleted 動作組已變更;未刪除任何內容 @msg no action set to save 沒有要儲存的動作組 +@msg the action set file can't be read in full 無法完整讀取動作組檔案 +@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 已載入「{name}」:已略過 {n} 個步驟 @msg not an action set file 不是動作組檔案 @msg the action set file is larger than 8 MiB 動作組檔案大於 8 MiB @msg the action set file holds more than 1,000 actions 動作組檔案包含超過 1,000 個動作 @@ -2309,7 +2360,7 @@ file-filter Templates 範本 @msg choose a folder to export to 請選擇要匯出至的資料夾 @msg the selection has no URL (or they differ) 選取範圍沒有 URL (或其 URL 各不相同) @msg select some type first 請先選取一些文字 -@msg kerning must be a number, "auto", or "optical" 字距微調必須是數字、「auto」或「optical」 +@msg kerning must be a number, "auto", or "optical" kerning 必須是數字、「auto」或「optical」 @msg the selection has no gradient fill 選取範圍沒有漸層填色 @msg the selection has no gradient stroke 選取範圍沒有漸層筆畫 @msg the selection has no pattern fill 選取範圍沒有圖樣填色 @@ -2452,12 +2503,19 @@ file-filter Templates 範本 @msg the Pixar file's image data is damaged Pixar 檔案的影像資料已損毀 @msg the JPEG 2000 file can't be read 無法讀取 JPEG 2000 檔案 @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 檔案包含不支援的色彩空間或 JPX 內容 +@msg the HEIC or HEIF file can't be read 無法讀取 HEIC 或 HEIF 檔案 +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC 或 HEIF 檔案包含無法讀取的影像類型 +@msg HEIC and HEIF images can't be opened in this build 此版本無法開啟 HEIC 和 HEIF 影像 @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 影像過大,無法讀取 ({_1} × {_2} 像素) @msg zipped 32-bit PSD image data with prediction can't be read 無法讀取使用預測的 ZIP 壓縮 32 位元 PSD 影像資料 @msg {depth}-bit PSD files in this colour mode can't be read 無法讀取此色彩模式的 {depth} 位元 PSD 檔案 @msg the PSD file is cut short PSD 檔案不完整 @msg the PSD file's header is damaged PSD 檔頭已損毀 @msg the PSD file's image data can't be read: {e} 無法讀取 PSD 檔案的影像資料:{e} +@msg 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} @msg invalid restored panel group 還原的面板群組無效 @msg Invalid panel layout; restored the default dock 面板配置無效;已還原預設停駐區 @@ -2466,7 +2524,7 @@ file-filter Templates 範本 @msg before must name a panel before 必須指定面板 @msg docking action contains an unknown panel or an excessive split path 停駐動作包含未知的面板或過長的分割路徑 @msg group must be true or false group 必須是 true 或 false -@msg panel is not in a dock group 面板不在停駐群組中 +@msg panel is not in a dock group panel 不在停駐群組中 @msg only a dock group above or below others collapses, not one beside another 只有位於其他群組上方或下方的停駐群組可以摺疊,並排的不行 @msg icon must be true or false icon 必須為 true 或 false @msg icon only docks a panel in the icon column, with no anchor icon 只會將面板停駐到圖示欄中,不能帶 anchor @@ -2547,11 +2605,19 @@ file-filter Templates 範本 @msg too many library panels were opened in this session 本次工作階段開啟的資料庫面板過多 @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 影像為 {_1} × {_2} 像素,有一邊超過 {LARGE_TRACE_SIDE}:仍會描圖,但速度較慢。使用精確度較低的預設集或較小的影像拷貝,描圖會較快。 @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 影像已以 {_1} × {_2} 像素描圖,由 {_3} × {_4} 縮小而來 (最多 {_5} 百萬像素)。 +@msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. 「扁平標誌」模式不使用 keepColors:它會描圖 logoColors 中指定的顏色,或它找到的顏色。 +@msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). 未描圖任何內容:描圖找到的顏色中沒有一種與 keepColors ({_1}) 相符。 +@msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. 未描圖任何內容:「忽略顏色」排除了 keepColors 所列顏色的所有區域。 +@msg Nothing was traced: the colors that match keepColors produce no paths. 未描圖任何內容:與 keepColors 相符的顏色未產生任何路徑。 +@msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors:描圖找到的顏色中沒有一種與 {c} 相符,因此該顏色未被描圖。 @msg `colorGroup` needs `library: "document"` `colorGroup` 需要 `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup` 需要 `palette: "documentLibrary"` @msg `colorGroup` needs `mode: "color"` `colorGroup` 需要 `mode: "color"` @msg no color group `{name}` in the document (see swatch.list) 文件中沒有顏色群組 `{name}` (請參閱 swatch.list) @msg Flat Logo traced the image at {_1} × {_2} pixels, averaged down from {_3} × {_4}. 「扁平標誌」模式已以 {_1} × {_2} 像素描圖,由 {_3} × {_4} 平均縮小而來。 +@msg byColor must be true or false byColor 必須是 true 或 false +@msg byColor can't split Auto Grouping groups: trace without Auto Grouping byColor 無法拆分自動群組產生的群組:請不使用自動群組來描圖 +@msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor 無法重新編組帶有即時 Pathfinder 效果的描圖:請不使用 byColor 進行擴充 # Links and Attributes: files and URLs handed to other apps. @msg Edit Original opens image and document files only; “{name}” is not one 「編輯原稿」只能開啟影像與文件檔案;「{name}」不屬於這兩類 @msg only http, https and mailto URLs open in the browser, not “{url}” 只有 http、https 和 mailto URL 會在瀏覽器中開啟,「{url}」不會 @@ -2589,6 +2655,7 @@ file-filter Templates 範本 Line {line} 第 {line} 行 Line {line}: {name}: {status} 第 {line} 行:{name}:{status} Imported. Press OK to keep the imported set. 已匯入。按一下「確定」以保留匯入的快速鍵組。 + Read Action Set File 讀取動作組檔案 Read Shortcut Set File 讀取快速鍵組檔案 Import Matches 匯入對應 Remember Import Matches 記住匯入對應 @@ -2837,6 +2904,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 @@ -3661,6 +3732,7 @@ digits Malayalam 馬來亞拉姆數字 Reset Transformation 重設變形 Replace With Font 取代為字體 Replace With Font From 取代字體來源 + Find In 尋找範圍 System 系統 Replaces the saved preset of that name. 取代同名的已儲存預設集。 Replace… 取代… @@ -3959,6 +4031,11 @@ digits Malayalam 馬來亞拉姆數字 Selected Swatches 選取的色票 Selected Swatches Only 僅限選取的色票 Selection 選取範圍 +# Marquee selection modes (draft translations; native review welcome). + Marquee Selection 框選 + Touching 接觸物件 + Fully Enclosed 完全包含 + Drag Direction 拖曳方向 Selection & Anchor Display 選取和錨點顯示 Selection Only 僅限選取範圍 Selection Tool 選取工具 @@ -4096,6 +4173,7 @@ digits Malayalam 馬來亞拉姆數字 Simulate Colored Paper 模擬有色紙張 Simulate Paper 模擬紙張 Double Column 雙欄 + Keep Tools Beside the Canvas 工具置於畫布旁 Single Left Quotation Mark 左單引號 Single Quotes: 單引號: Single Right Quotation Mark 右單引號 @@ -4344,6 +4422,15 @@ digits Malayalam 馬來亞拉姆數字 Tate-chu-yoko 縱中橫 Tate-chu-yoko Settings… 縱中橫設定… Tate-chu-yoko Settings 縱中橫設定 + Composite Fonts… 複合字體… + Composite Fonts 複合字體 + Export Composite Fonts 匯出複合字體 + Import Composite Fonts 匯入複合字體 + Delete Font 刪除字體 + Kanji 漢字 + Kana 假名 + Numerals 數字 + Scale at Center 從中心縮放 Up/Down: 上/下: Left/Right: 左/右: Temperature 色溫 @@ -4397,6 +4484,8 @@ digits Malayalam 馬來亞拉姆數字 Tile Type: 拼貼類型: tile {number} 拼貼 {number} Tiles are numbered across, then down; leave the range empty for every tile. 拼貼先由左至右再由上至下編號;範圍留空表示全部拼貼。 + Copies per Sheet (as many as fit, in a grid) 每張紙印多份 (盡量排滿,以格點排列) + Gutter: 間距: Tint 色調 Tip extends past the end point 尖端延伸超過終點 Tip on the end point 尖端位於終點 @@ -4712,6 +4801,7 @@ digits Malayalam 馬來亞拉姆數字 Zoom to Selection 縮放至選取範圍 Zoom Tool 縮放工具 Zoom with Mouse Wheel 使用滑鼠滾輪縮放 + Scroll Sideways with Cmd/Ctrl + Wheel 使用 Cmd/Ctrl + 滾輪水平捲動 Scrub Numeric Fields by Dragging 拖曳以調整數值欄位 @plural {n} file in {folder} {note}. Show the package folder?|{n} files in {folder} {note}. Show the package folder? {folder} 中有 {n} 個檔案{note}。要顯示封裝資料夾嗎? @plural {n} linked file not found|{n} linked files not found 找不到 {n} 個連結檔案 @@ -4879,7 +4969,7 @@ axis Both 兩者 Dock to Bottom 停駐到底部 Dock Control Bar 停駐控制列 Drag to float the toolbar 拖曳以浮動工具列 - Drag to the window's left edge to dock 拖曳到視窗左側邊緣以停駐 + Drag to the window's left or right edge to dock 拖曳到視窗左側或右側邊緣以停駐 Float Tool Group 浮動工具組 # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. @@ -5015,6 +5105,9 @@ axis Both 兩者 effect Soft 柔和 Edge 邊緣 System Title Bar 系統標題列 + VectorCraft Title Bar on Every Desktop 在所有桌面上使用 VectorCraft 標題列 + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. 設定了環境變數 VECTORCRAFT_TITLEBAR 時,由它選擇標題列。 + System Title Bar takes precedence over this option while it is on. 開啟系統標題列時,它優先於此選項。 Value Axis 數值軸 On Left Side 左側 On Right Side 右側 @@ -5200,6 +5293,11 @@ 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 {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}:不是 PNG、JPEG、WebP、TIFF 或 BMP 影像 +@msg the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ 影像為 {iw} × {ih} 像素,而工作區域在該寬度下為 {rw} × {rh} 像素:兩者的比例不同 +@msg `threshold` must be a number from 0 to 1 `threshold` 必須是 0 到 1 之間的數字 +@msg background {v}: a color such as "#808080" background {v}:顏色,例如 "#808080" +@msg comparing with an image file isn't available in the browser 瀏覽器中無法與影像檔案進行比較 @msg an image of {w} × {h} pixels is shown at reduced size {w} × {h} 像素的影像以縮小的尺寸顯示 Basic 基本 Fill and Stroke Controls 填色和筆畫控制項 @@ -5375,6 +5473,7 @@ lighting Light 光源 @msg {key} must be Unix seconds (an integer) or null {key} 必須是以秒為單位的 Unix 時間 (整數) 或 null @msg {key} must be an array of non-negative integer object ids {key} 必須是由非負整數物件 id 組成的陣列 @msg invalid object id {value}: expected a non-negative integer 無效的物件 id {value}:應為非負整數 +@msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` 中索引 {index} 處的項目為 {value},不是非負整數物件 id @msg no such object id {value} 沒有 id 為 {value} 的物件 @msg id {value} is a layer, not an object id {value} 是圖層,不是物件 @msg id {value} is a layer, not an object; move layers with layer.move id {value} 是圖層,不是物件;請使用 layer.move 移動圖層 @@ -5455,11 +5554,14 @@ axis Y Axis Y 軸 @msg Running “{name}”… 正在執行「{name}」… @msg a live effect runs while the document is drawn, so it can't be `heavy` 即時效果會在繪製文件時執行,因此不能是 `heavy` @msg an exporter always gets an export's budget: leave `budget` out 匯出增效模組一律使用匯出的預算:請移除 `budget` -@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees 「{_1}」需要大量運算預算(每次執行最多 {minutes} 分鐘,指令數為一般的 {times} 倍):請在使用者同意後以 `allowHeavy: true` 安裝 +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants 「{_1}」需要大量運算預算(每次執行最多 {minutes} 分鐘,指令數為一般的 {times} 倍):需要有人在桌面應用程式中核准,或在以 --allow-plugin-grants 啟動的工作階段中傳入 `allowHeavy: true` # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? 要安裝需要這些權限的「{name}」嗎? ID: {id} ID:{id} Version: {version} 版本:{version} + Install “{name}” in place of the plug-in with the same ID? 要安裝「{name}」來取代具有相同 ID 的增效模組嗎? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. 此檔案不是先前以此 ID 安裝的增效模組。它會取得為該 ID 保存的設定和快取。 +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees 「{name}」與 ID 為 {_1} 的增效模組是不同的模組,並會取得為其保存的設定和快取:請在使用者同意後以 `replace: true` 安裝 File SHA-256: {hash} 檔案 SHA-256:{hash} Read the objects it is given 讀取交給它的物件 Change, add and delete objects in the document 變更、新增和刪除文件中的物件 @@ -5467,21 +5569,43 @@ 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 將文件寫成其自有格式的檔案 Show its progress, see Cancel and write log lines 顯示進度、偵測取消要求並寫入記錄行 -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 權限 `progress` 需要增效模組 API 層級 {PROGRESS_API}:請設定 `api` {PROGRESS_API} + Keep settings of its own 保存自己的設定 + Keep a cache of its own, which you can clear 保存自己的快取,您可以清除它 + Clear Plug-in Cache 清除增效模組快取 +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 權限 `{permission}` 需要增效模組 API 層級 {PROGRESS_API}:請設定 `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} 模組以與主機不同的類型匯入 `env.{name}`:主機的類型為 {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` 模組匯入了 `env.{name}`,增效模組只有在增效模組 API 層級 {PROGRESS_API}(`api` {PROGRESS_API})且具有權限 `progress` 時才能使用它 -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network 模組匯入了主機未提供的 `{from}.{name}`:增效模組只能匯入 `env.vc_progress`、`env.vc_cancelled` 和 `env.vc_log`(增效模組 API 層級 2),不能使用 WASI、檔案或網路 +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` 模組匯入了 `env.{name}`,增效模組只有在增效模組 API 層級 {PROGRESS_API}(`api` {PROGRESS_API})且具有權限 `{permission}` 時才能使用它 +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network 模組匯入了主機未提供的 `{from}.{name}`:增效模組只能匯入 docs/plugins.md 列出的 `env.vc_*` 函式(增效模組 API 層級 2),不能使用 WASI、檔案或網路 @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` 為 {_1};此主機支援的增效模組 API 層級為 1 到 {API_LEVEL} +@msg `paintOutput` may only be true `paintOutput` 只能為 true +@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}` @msg at most {MAX_PERMISSIONS} `permissions` 最多 {MAX_PERMISSIONS} 個 `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 權限 `{shown}` 不適用於 `{_1}` 類型的增效模組 @msg permission `{shown}` is listed twice 權限 `{shown}` 列出了兩次 +@msg only a filter declares `inputs` 只有濾鏡才能宣告 `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` 列出了未知的物件類型 `{shown}`:請使用 type、image 或 symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` 列出了兩次 `{shown}` +@msg `inputs` needs `api` 2 or later `inputs` 需要 `api` 2 或更高 +@msg `inputs` needs the permission `doc.read` `inputs` 需要權限 `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) 記憶體預算(物件 {_1} 需要 {size} 位元組的輸入,剩餘 {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) 物件預算(超過 {MAX_OBJECTS} 個唯讀物件) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 權限 `{shown}` 保留給日後的增效模組 API;此主機不授予該權限 @msg unknown permission `{shown}` 未知權限 `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」需要權限 {asked}:請在使用者同意後以列出這些權限的 `grant` 安裝 +@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants 「{name}」需要權限 {asked}:需要有人在桌面應用程式中核准,或在以 --allow-plugin-grants 啟動的工作階段中傳入 `grant` +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` 和 `allowHeavy` 僅在以 --allow-plugin-grants 啟動的工作階段中生效:請在桌面應用程式中核准此外掛 +@msg Only the person at the keyboard can approve installing a plug-in 只有鍵盤前的人才能核准安裝外掛 @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed 「{name}」未獲授予其輸出所需的 `{p}`:未做任何變更 +@msg a heavy filter runs in the background, so it can't declare `commands` 重型濾鏡在背景執行,因此不能宣告 `commands` +@msg at most {MAX_COMMANDS} `commands` 最多 {MAX_COMMANDS} 個 `commands` +@msg command {name} is declared twice 指令 {name} 宣告了兩次 +@msg command {name}: `label` must be 1-64 printable characters 指令 {name}:`label` 必須是 1-64 個可列印字元 +@msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter 指令 {name}:請使用由 a-z 0-9 _ 組成、以字母開頭的 1-{MAX_COMMAND_ID} 個字元的 ID +@msg command {name}: {e} 指令 {name}:{e} +@msg only a filter or a live effect declares `commands` 只有濾鏡或即時效果才能宣告 `commands` +@msg “{name}” › {label}: {e} 「{name}」 › {label}:{e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模組的 SHA-256 與 `hash` 提供的不符:未安裝任何內容 # Actions panel: action sets (#3712). New Set… 新增組… @@ -5902,6 +6026,8 @@ breakText Glyphs 字符 File + Data Set Name 檔案 + 資料集名稱 Data Set Name 資料集名稱 @msg `datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName` `datasetFileNaming` 必須是 `fileNumber`、`fileDataSetName` 或 `dataSetName` + Each data set saves over the same file, which keeps only the last data set. 每個資料集都會儲存到同一個檔案並覆寫它,該檔案只保留最後一個資料集。 +@msg save the document with Save As before Save and Close 請先用「另存新檔」儲存文件,再使用「儲存並關閉」 @msg no free data set file name in destination folder 目的地資料夾中沒有可用的資料集檔案名稱 # Path Precision Tools add-on: Path Effects (Effect › Path Effects). @@ -6022,9 +6148,20 @@ breakText Glyphs 字符 Reset Symbol Instance 重設符號實體 @msg type must be static or dynamic, not {v} type 必須是 static 或 dynamic,而不是 {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` 是靜態符號:請先將其符號類型設為動態 +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change 在符號實體中選取的路徑、複合路徑或文字只能變更填色和筆畫的純色 @msg path must be a list of child indices path 必須是子項索引清單 @msg no object at path {shown} in the symbol's art 符號圖稿中路徑 {shown} 處沒有物件 @msg give fill, stroke, opacity and/or blend 請提供 fill、stroke、opacity 和/或 blend +@msg {key} cannot be set on a guide 無法為參考線設定 {key} +@msg {key} cannot be set on a blend 無法為漸變設定 {key} +@msg {key} cannot be set on an envelope 無法為封套設定 {key} +@msg {key} cannot be set on a gradient mesh 無法為漸層網格設定 {key} +@msg {key} cannot be set on a Repeat 無法為重複設定 {key} +@msg {key} cannot be set on a placed document 無法為置入的文件設定 {key} +@msg {key} cannot be set on an Intertwine group 無法為交織群組設定 {key} +@msg stroke cannot be set on a symbol instance 無法為符號範例設定 stroke +@msg fill on a symbol instance must be a solid color or none 符號範例的 fill 必須是純色或 none +@msg {key} cannot be set on an image without a Convert to Shape or Outline Object effect on it 無法為沒有「轉換為形狀」或「外框物件」效果的影像設定 {key} @msg pass newName, registration, exportType, nineSlice and/or type 請提供 newName、registration、exportType、nineSlice 和/或 type Text Flow 文字流向 By Rows 依列 @@ -6054,6 +6191,7 @@ breakText Glyphs 字符 Create Swatch Legend 建立色票圖例 Create Swatch Legend… 建立色票圖例… HEX HEX + Help Bar 說明列 # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools 文字工具 Text Tweaker… 文字微調… @@ -6119,3 +6257,44 @@ 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 plug-in of kind `{_2}`, not an importer `{_1}` 是 `{_2}` 類型的增效模組,不是匯入增效模組 +@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_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` + +# Safe Mode + Safe Mode… 安全模式… + Safe Mode 安全模式 + Help › Safe Mode… 說明 › 安全模式… + Enable on Re-launch 下次啟動時啟用 + The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. VectorCraft 上次啟動時在載入增效模組 {name} 期間停止。該增效模組不一定是原因。 + Disable and Continue 停用並繼續 + Start Normally 正常啟動 + Start in Safe Mode 以安全模式啟動 + VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft 處於安全模式:在正常啟動之前,不會載入或執行任何增效模組。 + Disabled plug-ins 已停用的增效模組 + No plug-ins are disabled. 沒有停用的增效模組。 +@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally 安全模式已開啟:在 VectorCraft 正常啟動之前,增效模組不會載入或執行 +@msg give `id` or `file` (see plugin.safeMode) 請提供 `id` 或 `file` (請參閱 plugin.safeMode) +@msg that plug-in isn't disabled (see plugin.safeMode) 該增效模組未停用 (請參閱 plugin.safeMode) +@msg Couldn't save the disabled plug-ins for the next launch 無法為下次啟動儲存已停用的增效模組 +@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode 展開外觀:在安全模式下無法展開選取範圍中的增效模組效果 + +# Align to Pixel Grid + Align to Pixel Grid 對齊像素格點 +@msg only paths and compound paths can be aligned to the pixel grid 只有路徑和複合路徑可以對齊像素格點 +@msg side is left or right, not `{s}` side 應為 left 或 right,而不是 `{s}` diff --git a/crates/ui-egui/src/ime.rs b/crates/ui-egui/src/ime.rs index 95c0c0323..7f4893c6f 100644 --- a/crates/ui-egui/src/ime.rs +++ b/crates/ui-egui/src/ime.rs @@ -109,7 +109,7 @@ pub(crate) fn install_hooks(ctx: &Context) { } /// The base eligibility every custom-descriptor consumer shares: a hidden viewport, the Home -/// screen, a modal dialog, the palette, a file sheet, an unfocused window, another egui keyboard +/// screen, a modal dialog, an update dialog, the palette, a file sheet, an unfocused window, another egui keyboard /// owner or no Type-tool text edit all mean this pass publishes a stock descriptor or none. One /// helper so the pre-UI route test, the post-UI finalizer and the end-of-pass observation cannot /// drift apart; Home in particular is an ineligible route even though the Type tool keeps its @@ -118,6 +118,7 @@ fn pass_eligible(app: &VectorcraftApp, ctx: &Context) -> bool { !app.viewport_hidden(ctx) && !crate::menus::home_showing(app) && app.ui.dialog.is_none() + && !crate::updates_ui::blocks_input(app) && !app.ui.palette_open && !app.file_sheet_open() && ctx.input(|input| input.focused) @@ -801,6 +802,31 @@ pub(crate) fn write_output(ctx: &Context, update: Option, rect #[cfg(test)] mod tests { use super::*; + use serde_json::json; + use vectorcraft_engine::Session; + + /// The update prompt ends the Type tool's IME route, as a dialog does. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn the_update_prompt_ends_the_type_tools_route() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 80, "text": "Text", "size": 12, "color": "#000000"})).unwrap()["id"].as_u64().unwrap(); + let view = app.view_info(); + app.session.select_tool("type", view).unwrap(); + app.session.set_tool_option("textSelection", &json!({"id": id, "start": 4, "end": 4})); + assert!(app.session.tool_wants_text()); + let ctx = Context::default(); + let eligible = |app: &VectorcraftApp| { + let mut eligible = false; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| eligible = pass_eligible(app, ui.ctx())); + out.textures_delta.clear(); + eligible + }; + assert!(eligible(&app)); + crate::updates_ui::open_prompt_for_test(&mut app); + assert!(!eligible(&app), "no canvas descriptor behind the prompt"); + } /// A changed context generation or IME purpose for the same owner requires a replacement reset, /// while an unchanged descriptor keeps the existing request. diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index d1e34e38b..cc6f5d226 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -21,10 +21,11 @@ pub use export::{EXPORT_CANCELLED, cancel_exports, export, export_for_screens, e pub(crate) use export::{ export_files, exported_with_notes, file_url, open_in_app, open_original, reveal_path, run_to_file, save_command_output_named, }; -pub(crate) use open::{EXPORT_PRESETS, import_presets, opened}; +pub(crate) use open::report_installed; +pub(crate) use open::{EXPORT_PRESETS, import_presets, open_with_on, 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_path_with, 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/export.rs b/crates/ui-egui/src/io/export.rs index 1b438adf9..562dc0e00 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -33,6 +33,9 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option Result let open_location = params.get("openLocation").and_then(Value::as_bool).unwrap_or(false); let folder = params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string(); // Without a folder the files come back in the reply, which the caller waits for. - let in_background = app.session.prefs.background_export && app.services.write_shared.is_some() && !is_web(app) && !folder.is_empty(); + let in_background = + app.session.prefs.background_export && app.services.write_shared.is_some() && !is_web(app) && !folder.is_empty() && id != "assets.export3D"; if !in_background { // Made at once, after the exports and saves started before it. background::wait_all(app); diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 4f507fe30..a75171ae3 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -1,3 +1,4 @@ +use vectorcraft_engine::cmd::plugin::InstallApproval; use vectorcraft_plugins::permissions; use super::save::suggested; @@ -13,6 +14,11 @@ const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft" /// (`.jsx`, `.jsxinc`, `.jsxbin`) shows its check (Check Script…). → `document.open`'s result for /// a document opened now (its `warnings` say what didn't come in as it was), else null. pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option) -> Result { + open_bytes_with(app, name, bytes, path, &Value::Null) +} + +/// [`open_bytes`], a document opening with `document.open`'s params `p`. +fn open_bytes_with(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option, p: &Value) -> Result { let opens = opens_as(name); // A WebAssembly plug-in is installed. if opens == Some(OpensAs::Plugin) { @@ -53,7 +59,7 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti { return Ok(Value::Null); } - open_document(app, name, bytes, path, &Value::Null) + open_document(app, name, bytes, path, p) } /// The kinds of file File → Open installs, imports, checks, shows in the library panel or the @@ -101,6 +107,7 @@ pub fn opens_as(name: &str) -> Option { (vectorcraft_engine::cmd::printpresets::PRESET_EXTS, "print.presets.import", "print presets"), (vectorcraft_engine::cmd::perspgrid::PRESET_EXTS, "perspective.presets.import", "perspective grid presets"), (fileio::SCREEN_EXPORT_PRESET_EXTS, "document.exportPresets.import", EXPORT_PRESETS), + (vectorcraft_engine::cmd::compositefonts::EXTS, "type.compositeFonts.import", "composite fonts"), ]; if vectorcraft_engine::cmd::plugin::EXTS.contains(&ext.as_str()) { Some(OpensAs::Plugin) @@ -174,70 +181,104 @@ 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"), Some(permissions::Permission::Progress) => tl!("Show its progress, see Cancel and write log lines"), + Some(permissions::Permission::Settings) => tl!("Keep settings of its own"), + Some(permissions::Permission::Cache) => tl!("Keep a cache of its own, which you can clear"), None => return permissions::display_text(name, permissions::MAX_SHOWN_FIELD), }; words.to_string() } /// Installs the plug-in module that `module` names (`plugin.install`'s `path`, or `dataBase64` -/// with `name`) → `plugin.install`'s result. A plug-in that lists permissions the user hasn't -/// granted it yet (`grant`) asks them first, showing its name, id, version, file hash and what each -/// permission lets it do, and so does a filter that asks for a heavy budget the user hasn't allowed -/// yet (`allowHeavy`); OK installs it if the module still has the hash the prompt showed, Cancel -/// installs nothing → `{pending}` while it asks. Every -/// text from the manifest is shown with its hidden characters escaped and its length capped +/// with `name`) → `plugin.install`'s result. A plug-in that lists permissions no person has granted +/// it yet asks first, showing its name, id, version, file hash and what each permission lets it +/// do, so does a filter that asks for a heavy budget, and so does a module that would take the +/// place of another one with its id and get its settings and cache (`replace`); OK installs it if +/// the module still has the hash the prompt showed, Cancel installs nothing → `{pending}` while it +/// asks. The `grant` and `allowHeavy` of a request are ignored: only the dialog's OK approves. +/// Every text from the manifest is shown with its hidden characters escaped and its length capped /// ([`permissions::display_text`]). pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result { - let heavy_allowed = module.get("allowHeavy").and_then(Value::as_bool) == Some(true); - let granted = module.get("grant").is_some_and(|g| !g.is_null()); - if !heavy_allowed || !granted { - let given = |k: &str| module.get(k).map(|v| (k.to_string(), v.clone())); - let source = Value::Object(["path", "dataBase64", "name"].into_iter().filter_map(given).collect()); - // What OK installs: the same module, with what the user has agreed to so far, pinned to - // the bytes the prompt shows (`hash`). - let mut answered = - Value::Object(["path", "dataBase64", "name", "replace", "grant", "allowHeavy", "hash"].into_iter().filter_map(given).collect()); - let info = app.run("plugin.inspect", source)?; - if answered.get("hash").is_none() { - answered["hash"] = info["hash"].clone(); - } - let shown = |k: &str, max: usize| permissions::display_text(info[k].as_str().unwrap_or_default(), max); - let name = shown("name", permissions::MAX_SHOWN_NAME); - if !granted && info["needsApproval"] == true { - let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for these permissions?"), &[("name", &name)]); - let mut lines = vec![crate::i18n::fmt(tl!("ID: {id}"), &[("id", &shown("id", permissions::MAX_SHOWN_FIELD))])]; - let version = shown("version", permissions::MAX_SHOWN_FIELD); - if !version.is_empty() { - lines.push(crate::i18n::fmt(tl!("Version: {version}"), &[("version", &version)])); - } - lines.push(crate::i18n::fmt(tl!("File SHA-256: {hash}"), &[("hash", &shown("hash", 64))])); - lines.push(String::new()); - let asked = info["permissions"].as_array().cloned().unwrap_or_default(); - lines.extend(asked.iter().filter_map(Value::as_str).map(|p| format!("• {}", permission_words(p)))); - let mut again = answered; - again["grant"] = Value::Array(asked); - dialogs::confirm::ask(app, &message, &lines.join("\n"), "ui.installPlugin", again); - return Ok(json!({ "pending": dialogs::confirm::KIND })); - } - if !heavy_allowed && info["budget"] == "heavy" { - let (minutes, times) = vectorcraft_plugins::heavy_terms(); - let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for a heavy budget?"), &[("name", &name)]); - let detail = crate::i18n::fmt( - tl!( - "Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it." - ), - &[("minutes", &minutes.to_string()), ("times", ×.to_string())], - ); - let mut allowed = answered; - allowed["allowHeavy"] = json!(true); - dialogs::confirm::ask(app, &message, &detail, "ui.installPlugin", allowed); - return Ok(json!({ "pending": dialogs::confirm::KIND })); - } + let given = |k: &str| module.get(k).map(|v| (k.to_string(), v.clone())); + let source = Value::Object(["path", "dataBase64", "name"].into_iter().filter_map(given).collect()); + let mut install = Value::Object(["path", "dataBase64", "name", "replace", "hash"].into_iter().filter_map(given).collect()); + let info = app.run("plugin.inspect", source)?; + let (needs_grant, heavy) = (info["needsApproval"] == true, info["budget"] == "heavy"); + // `replace: false` asks for no replacing, so only a module without `replace` asks. + let asks_replace = module.get("replace").is_none_or(Value::is_null) && info["needsReplace"] == true; + if !needs_grant && !heavy && !asks_replace { + let r = app.run("plugin.install", install)?; + report_installed(app, &r); + return Ok(r); } - let r = app.run("plugin.install", module)?; + // Pinned to the bytes the prompt shows. + if install.get("hash").is_none() { + install["hash"] = info["hash"].clone(); + } + let shown = |k: &str, max: usize| permissions::display_text(info[k].as_str().unwrap_or_default(), max); + let name = shown("name", permissions::MAX_SHOWN_NAME); + let identity = [ + crate::i18n::fmt(tl!("ID: {id}"), &[("id", &shown("id", permissions::MAX_SHOWN_FIELD))]), + crate::i18n::fmt(tl!("File SHA-256: {hash}"), &[("hash", &shown("hash", 64))]), + ] + .join("\n"); + let takes_place = tl!("This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID."); + if asks_replace { + install["replace"] = json!(true); + } + // A client may pass `replace` itself, so taking another module's place alone needs no approval + // that only a person can give. + if !needs_grant && !heavy { + let message = crate::i18n::fmt(tl!("Install “{name}” in place of the plug-in with the same ID?"), &[("name", &name)]); + dialogs::confirm::ask(app, &message, &format!("{identity}\n\n{takes_place}"), "ui.installPlugin", install); + return Ok(json!({ "pending": dialogs::confirm::KIND })); + } + let asked = info["permissions"].as_array().cloned().unwrap_or_default(); + let mut paragraphs = vec![]; + if needs_grant { + let mut lines = vec![crate::i18n::fmt(tl!("ID: {id}"), &[("id", &shown("id", permissions::MAX_SHOWN_FIELD))])]; + let version = shown("version", permissions::MAX_SHOWN_FIELD); + if !version.is_empty() { + lines.push(crate::i18n::fmt(tl!("Version: {version}"), &[("version", &version)])); + } + lines.push(crate::i18n::fmt(tl!("File SHA-256: {hash}"), &[("hash", &shown("hash", 64))])); + lines.push(String::new()); + lines.extend(asked.iter().filter_map(Value::as_str).map(|p| format!("• {}", permission_words(p)))); + paragraphs.push(lines.join("\n")); + install["grant"] = Value::Array(asked.clone()); + } + if heavy { + let (minutes, times) = vectorcraft_plugins::heavy_terms(); + paragraphs.push(crate::i18n::fmt( + tl!( + "Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it." + ), + &[("minutes", &minutes.to_string()), ("times", ×.to_string())], + )); + install["allowHeavy"] = json!(true); + } + if asks_replace { + paragraphs.push(if needs_grant { takes_place.to_string() } else { format!("{identity}\n\n{takes_place}") }); + } + let message = if needs_grant { + crate::i18n::fmt(tl!("Install “{name}”, which asks for these permissions?"), &[("name", &name)]) + } else { + crate::i18n::fmt(tl!("Install “{name}”, which asks for a heavy budget?"), &[("name", &name)]) + }; + let approval = InstallApproval { + id: info["id"].as_str().unwrap_or_default().to_string(), + hash: info["hash"].as_str().unwrap_or_default().to_string(), + permissions: asked.iter().filter_map(Value::as_str).map(str::to_string).collect(), + heavy, + }; + let pending = dialogs::plugin::PendingInstall { message, detail: paragraphs.join("\n\n"), params: install, approval }; + Ok(dialogs::plugin::ask_install(app, pending)) +} + +/// Says in the status bar that `plugin.install`'s result `r` was installed. +pub(crate) fn report_installed(app: &mut VectorcraftApp, r: &Value) { app.status(format!("Installed plug-in {}", permissions::display_text(r["name"].as_str().unwrap_or_default(), permissions::MAX_SHOWN_NAME))); - Ok(r) } /// Where File → Open… starts: the folder of the most recently opened file, else the active @@ -257,6 +298,102 @@ 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, 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. A file the user picked is read off the UI thread where that is available +/// ([`crate::opening`]), and the result is then `{background: true, status}`; with `path` the file +/// opens before the reply, as automation needs. +pub fn open_with(app: &mut VectorcraftApp, p: &Value) -> Result { + open_with_on(app, p, picks_a_file(p) && crate::opening::available()) +} + +/// Do File › Open With's parameters leave the file to the picker? +fn picks_a_file(p: &Value) -> bool { + p.get("path").and_then(Value::as_str).is_none_or(str::is_empty) +} + +/// [`open_with`]; with `background`, the importer runs off the UI thread +/// ([`crate::opening::start_with`]), its progress window offering Cancel. +pub(crate) fn open_with_on(app: &mut VectorcraftApp, p: &Value, background: bool) -> 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 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, "picked": picks_a_file(p) })); + return Ok(Value::Null); + } + let mut o = json!({ "importer": importer }); + if let Some(params) = given { + o["params"] = params.clone(); + } + let mut bytes = read(app, &path)?; + if background { + match crate::opening::start_with(app, &path, bytes, o.clone()) { + Ok(r) => return Ok(r), + // No thread: at once. + Err(back) => bytes = back, + } + } + 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); + } + 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) { @@ -320,9 +457,19 @@ pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result open_file(app, path, false) } +/// [`open_path`], with `params` for the importer plug-in that opens the file, if one does. +pub fn open_path_with(app: &mut VectorcraftApp, path: &str, params: &Value) -> Result { + open_file_with(app, path, false, &json!({ "params": params })) +} + /// [`open_path`]; with `background`, a document (not a file that installs, imports or asks /// first) is decoded off the UI thread ([`crate::opening::start`]). pub(crate) fn open_file(app: &mut VectorcraftApp, path: &str, background: bool) -> Result { + open_file_with(app, path, background, &Value::Null) +} + +/// [`open_file`], a document opening with `document.open`'s params `p`. +fn open_file_with(app: &mut VectorcraftApp, path: &str, background: bool, p: &Value) -> Result { // Automation confined to some folders reaches no file outside them, open in a tab or not. vectorcraft_engine::file_access::check_read(path)?; // File › Open, Open Recent, the Home screen, a drop and the Finder all focus a document that @@ -354,6 +501,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. @@ -369,7 +518,7 @@ pub(crate) fn open_file(app: &mut VectorcraftApp, path: &str, background: bool) Err(back) => bytes = back, } } - let r = open_bytes(app, path, &bytes, Some(path.to_string()))?; + let r = open_bytes_with(app, path, &bytes, Some(path.to_string()), p)?; note_recent(app, path); Ok(r) } @@ -382,7 +531,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/io/save.rs b/crates/ui-egui/src/io/save.rs index 98e52a2c3..e9860e99a 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -129,6 +129,7 @@ const FILE_TYPES: &[(&str, &[&str])] = &[ ("Print presets", vectorcraft_engine::cmd::printpresets::PRESET_EXTS), ("Action sets", &[crate::panels::actions::ACTIONS_EXT]), ("Export presets", fileio::SCREEN_EXPORT_PRESET_EXTS), + ("Composite fonts", vectorcraft_engine::cmd::compositefonts::EXTS), ("CSS", &["css"]), ("PostScript", &["ps"]), ]; diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 572c6b175..d32ae53d5 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -3,16 +3,18 @@ //! user's overrides. //! //! Entry keys are VectorCraft's everywhere outside this module: a command id, `tool:`, or -//! `panel:` for the Window menu item showing a panel (`window.panel {panel}`). Chord texts -//! outside it are VectorCraft's too (`Cmd+Shift+]`, `Comma`, `Period`, `~` for the backtick key, -//! `=` for Plus with modifiers). Inside an [`Overrides`] keys and texts are the crate's. +//! `panel:` for the Window menu item showing a panel (`window.panel {panel}`), `effect:` +//! for an Effect menu item, or `plugin:` for a filter plug-in's Object › Plug-ins item +//! (`plugin.dialog {id}`). Chord texts outside it are VectorCraft's too (`Cmd+Shift+]`, `Comma`, +//! `Period`, `~` for the backtick key, `=` for Plus with modifiers). Inside an [`Overrides`] keys +//! and texts are the crate's. -use std::sync::{OnceLock, RwLock}; +use std::sync::{Arc, Mutex, RwLock}; use craft_keymap::{Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Lookup, Mods, Overrides, Platform, Scope, Scopes}; use serde_json::{Value, json}; -use crate::shortcut_editor::{self, Entry}; +use crate::shortcut_editor::{self, EffectItem, Entry, Snapshot}; /// Menu commands whose label flips between Show and Hide (see `menus::dynamic_label`). pub const TOGGLES: &[&str] = &[ @@ -38,37 +40,95 @@ pub const TOGGLES: &[&str] = &[ /// The command an entry key runs. pub fn command_of(key: &str) -> CommandRef { + if key.starts_with("effect:") { + return command_in(key, &shortcut_editor::effect_items()); + } + command_in(key, &[]) +} + +/// The command an entry key runs while the Effect menu holds `effects`. +fn command_in(key: &str, effects: &[EffectItem]) -> CommandRef { if let Some(panel) = key.strip_prefix("panel:") { return CommandRef::with_params("window.panel", json!({ "panel": panel })); } - // An Effect menu item (#3296): what its item runs, its dialog unless it has no options. + // An Effect menu item (#3296): what its item runs, its dialog unless it has no options. An + // effect the menu does not hold (a plug-in removed for now) is named by its dialog. if let Some(effect) = key.strip_prefix("effect:") { - let command = shortcut_editor::effect_items().iter().find(|e| e.effect == effect).map_or("effect.dialog", |e| e.command); + let command = effects.iter().find(|e| e.effect == effect).map_or("effect.dialog", |e| e.command); return CommandRef::with_params(command, json!({ "effect": effect })); } + // A filter plug-in's Object › Plug-ins item, installed or not: what its item runs. + if let Some(id) = key.strip_prefix("plugin:") { + return CommandRef::with_params("plugin.dialog", json!({ "id": id })); + } CommandRef::new(key) } -/// The entry key of a command: `panel:` for `window.panel {panel}`. +/// `overrides` with each Effect menu item's shortcut under the command its item runs while the +/// menu holds `effects`: a shortcut saved while a plug-in effect was missing, or while it had +/// options it no longer has, stays that effect's. +pub(crate) fn effect_keys_in(overrides: &Overrides, effects: &[EffectItem]) -> Overrides { + let mut out = overrides.clone(); + for (k, texts) in overrides.iter() { + let entry = entry_of_stored(k); + if !entry.starts_with("effect:") { + continue; + } + let now = command_in(&entry, effects).key(); + if now != k && out.get(&now).is_none() { + out.remove(k); + let _ = out.insert(now, texts.to_vec()); + } + } + out +} + +/// The entry key of a command: `panel:` for `window.panel {panel}`, `effect:` for an +/// Effect menu item and `plugin:` for `plugin.dialog {id}`. pub fn entry_key(command: &CommandRef) -> String { match (command.id.as_str(), command.params.get("panel").and_then(Value::as_str)) { ("window.panel", Some(panel)) => format!("panel:{panel}"), ("effect.dialog" | "effect.apply", _) if command.params.get("effect").and_then(Value::as_str).is_some() => { format!("effect:{}", command.params.get("effect").and_then(Value::as_str).unwrap_or_default()) } + ("plugin.dialog", _) if command.params.get("id").and_then(Value::as_str).is_some() => { + format!("plugin:{}", command.params.get("id").and_then(Value::as_str).unwrap_or_default()) + } _ => command.key(), } } /// The [`Overrides`] key of an entry key. pub fn stored_key(key: &str) -> String { - if key.starts_with("panel:") || key.starts_with("effect:") { command_of(key).key() } else { key.to_string() } + if key.starts_with("panel:") || key.starts_with("effect:") || key.starts_with("plugin:") { command_of(key).key() } else { key.to_string() } +} + +/// An Effect menu item's [`Overrides`] key under the command its item does not run now +/// (`effect.apply` for one that opens its dialog, and the other way round). +fn other_stored_key(key: &str) -> Option { + let effect = key.strip_prefix("effect:")?; + let other = if command_of(key).id == "effect.apply" { "effect.dialog" } else { "effect.apply" }; + Some(CommandRef::with_params(other, json!({ "effect": effect })).key()) +} + +/// The texts of an entry's override, saved under its [`stored_key`] or, for an Effect menu item, +/// under its [`other_stored_key`]. +pub(crate) fn stored_texts<'a>(overrides: &'a Overrides, key: &str) -> Option<&'a [String]> { + overrides.get(&stored_key(key)).or_else(|| other_stored_key(key).and_then(|k| overrides.get(&k))) +} + +/// Removes an entry's override, under either key an Effect menu item's can have. +pub(crate) fn remove_override(overrides: &mut Overrides, key: &str) { + overrides.remove(&stored_key(key)); + if let Some(k) = other_stored_key(key) { + overrides.remove(&k); + } } /// The entry key of an [`Overrides`] key. pub fn entry_of_stored(key: &str) -> String { CommandRef::parse_key(key) - .filter(|c| matches!(c.id.as_str(), "window.panel" | "effect.dialog" | "effect.apply")) + .filter(|c| matches!(c.id.as_str(), "window.panel" | "effect.dialog" | "effect.apply" | "plugin.dialog")) .map_or_else(|| key.to_string(), |c| entry_key(&c)) } @@ -132,7 +192,7 @@ fn vc_binding(text: &str) -> Option { /// Every listed entry as the crate sees it, in dispatch priority: commands (engine, then UI), /// panels, tools. Each tool is its own group: tools never cycle. -fn catalogue() -> Vec { +fn catalogue(snapshot: &Snapshot, p: Platform) -> Vec { let rank = |e: &Entry| { if e.is_tool { 2 @@ -142,14 +202,14 @@ fn catalogue() -> Vec { 0 } }; - let mut entries: Vec<&Entry> = shortcut_editor::entries().iter().collect(); + let mut entries: Vec<&Entry> = snapshot.entries.iter().collect(); entries.sort_by_key(|e| rank(e)); entries .into_iter() .map(|e| { let mut path: Vec = e.group.split(" › ").filter(|s| !s.is_empty()).map(str::to_string).collect(); path.push(e.label.clone()); - let mut b = Bindable::new(command_of(&e.key), e.label.clone(), Scope::app()).with_path(path); + let mut b = Bindable::new(command_in(&e.key, &snapshot.effects), e.label.clone(), Scope::app()).with_path(path); if let Some(tool) = e.key.strip_prefix("tool:") { b = b.with_kind(Kind::Tool { group: Some(tool.to_string()) }); } @@ -159,7 +219,7 @@ fn catalogue() -> Vec { if let Some(d) = shortcut_editor::default_of(&e.key).and_then(vc_binding) { b = b.with_default(d); } - for alias in default_aliases(&e.key) { + for alias in default_aliases(&e.key, p) { if let Some(d) = vc_binding(alias) { b = b.with_default(d); } @@ -170,14 +230,22 @@ fn catalogue() -> Vec { } /// Additional defaults for commands whose primary shortcut remains in their command spec. -fn default_aliases(key: &str) -> &'static [&'static str] { +fn default_aliases(key: &str, p: Platform) -> &'static [&'static str] { match key { + "edit.cut" if !p.is_mac() => &["F2"], + "edit.copy" if !p.is_mac() => &["F3"], + "edit.paste" if !p.is_mac() => &["F4"], "paint.lastColor" => &["Shift+,"], "paint.lastGradient" => &["Shift+."], _ => &[], } } +/// Cut, Copy and Paste with their primary shortcut and the function key [`default_aliases`] adds +/// outside macOS. +pub(crate) const EDIT_FUNCTION_KEYS: [(&str, &str, &str); 3] = + [("edit.cut", "Cmd+X", "F2"), ("edit.copy", "Cmd+C", "F3"), ("edit.paste", "Cmd+V", "F4")]; + /// On the web, the browser's operating system once egui reports it ([`note_os`]). static WEB_PLATFORM: RwLock> = RwLock::new(None); @@ -200,33 +268,78 @@ pub fn note_os(ctx: &egui::Context) { } } -/// The value for `p` in `cells`, made on first use. Each platform has its own cell, because the -/// web build learns the browser's platform only after the first frame. -pub(crate) fn per_platform(cells: &'static [OnceLock; 3], p: Platform, make: impl FnOnce() -> T) -> &'static T { - let [mac, windows, other] = cells; - let cell = match p { - Platform::Mac => mac, - Platform::Windows => windows, - _ => other, - }; - cell.get_or_init(make) +/// The keymap with the default shortcuts for [`platform`] and the plug-ins installed now. +pub fn template() -> Arc { + template_for(&shortcut_editor::snapshot()) } -/// The keymap with the default shortcuts for [`platform`], built once per platform. -pub fn template() -> &'static Keymap { - static TEMPLATES: [OnceLock; 3] = [OnceLock::new(), OnceLock::new(), OnceLock::new()]; +/// The keymap with the default shortcuts for [`platform`] and the entries of `snapshot`, built +/// once per platform and plug-in revision. Each platform has its own cell, because the web build +/// learns the browser's platform only after the first frame. +pub(crate) fn template_for(snapshot: &Snapshot) -> Arc { + /// A template and the plug-in revision it was built for. + type Built = Option<(u64, Arc)>; + static TEMPLATES: Mutex<[Built; 3]> = Mutex::new([None, None, None]); let p = platform(); - per_platform(&TEMPLATES, p, || keymap_for(p)) + let at = match p { + Platform::Mac => 0, + Platform::Windows => 1, + _ => 2, + }; + let cached = TEMPLATES.lock().unwrap_or_else(|e| e.into_inner())[at].clone(); + if let Some((_, km)) = cached.filter(|(revision, _)| *revision == snapshot.revision) { + return km; + } + let km = Arc::new(keymap_for(p, snapshot)); + let mut cells = TEMPLATES.lock().unwrap_or_else(|e| e.into_inner()); + if cells[at].as_ref().is_none_or(|(revision, _)| *revision <= snapshot.revision) { + cells[at] = Some((snapshot.revision, km.clone())); + } + km } -/// The default keymap of `p`. The chords the operating system takes can't be assigned. -fn keymap_for(p: Platform) -> Keymap { - Keymap::new(catalogue(), Scopes::new(), p).with_reserved(craft_keymap::reserved::system(p)) +/// The default keymap of `p` with the entries of `snapshot`. The chords the operating system +/// takes can't be assigned. +pub(crate) fn keymap_for(p: Platform, snapshot: &Snapshot) -> Keymap { + Keymap::new(catalogue(snapshot, p), Scopes::new(), p).with_reserved(craft_keymap::reserved::system(p)) } /// The keymap with `overrides` applied. pub fn keymap_with(overrides: &Overrides) -> Keymap { - template().clone().with_overrides(overrides.clone()) + keymap_in(&shortcut_editor::snapshot(), overrides) +} + +/// The keymap of `snapshot`'s entries with `overrides` applied, each Effect menu item's shortcut +/// under the command its item runs now. +pub(crate) fn keymap_in(snapshot: &Snapshot, overrides: &Overrides) -> Keymap { + let defaults = template_for(snapshot); + let moved = effect_keys_in(overrides, &snapshot.effects); + let resolved = edit_alias_overrides(&moved, defaults.platform()); + defaults.as_ref().clone().with_overrides(resolved) +} + +/// When a saved override assigns F2, F3 or F4 elsewhere without changing its edit command, +/// keep only that edit command's primary shortcut so the saved assignment runs. +pub(crate) fn edit_alias_overrides(overrides: &Overrides, p: Platform) -> Overrides { + let mut resolved = overrides.clone(); + if p.is_mac() { + return resolved; + } + for (key, primary, alias) in EDIT_FUNCTION_KEYS { + if overrides.get(key).is_some() { + continue; + } + let Some(binding) = vc_binding(alias) else { continue }; + let assigned_elsewhere = overrides.iter().any(|(other, texts)| { + other != key + && shortcut_editor::entry(&entry_of_stored(other)).is_some() + && texts.iter().any(|text| Binding::parse(text).is_ok_and(|b| b == binding)) + }); + if assigned_elsewhere { + let _ = resolved.insert(key, vec![vc_to_crate(primary)]); + } + } + resolved } /// The command a chord runs, the first for tools sharing it. @@ -244,7 +357,7 @@ pub fn lookup(km: &Keymap, chord: &Chord) -> Option { pub fn effective_with(km: &Keymap, key: &str) -> Option { let command = command_of(key); if km.bindable(&command).is_none() { - return match km.overrides().get(&stored_key(key)) { + return match stored_texts(km.overrides(), key) { Some(texts) => texts.first().filter(|t| !t.trim().is_empty()).map(|t| crate_to_vc(t)), None => shortcut_editor::default_of(key).map(str::to_string), }; @@ -264,6 +377,9 @@ pub fn effective_with(km: &Keymap, key: &str) -> Option { /// Sets an entry's shortcut in VectorCraft's terms: `""` for none. pub fn set_override(overrides: &mut Overrides, key: &str, text: &str) { let texts = if text.trim().is_empty() { vec![] } else { vec![vc_to_crate(text)] }; + if let Some(k) = other_stored_key(key) { + overrides.remove(&k); + } // Only an over-long key or text is refused; such an entry could not be stored anyway. let _ = overrides.insert(stored_key(key), texts); } @@ -279,11 +395,11 @@ pub fn overrides_from(pairs: &[(&str, &str)]) -> Overrides { /// An entry's stored shortcut in VectorCraft's terms (`""` for none), when it has an override. pub fn override_text(overrides: &Overrides, key: &str) -> Option { - overrides.get(&stored_key(key)).map(|t| t.first().map(|t| crate_to_vc(t)).unwrap_or_default()) + stored_texts(overrides, key).map(|t| t.first().map(|t| crate_to_vc(t)).unwrap_or_default()) } pub fn has_override(overrides: &Overrides, key: &str) -> bool { - overrides.get(&stored_key(key)).is_some() + stored_texts(overrides, key).is_some() } /// Serde for [`Overrides`] in VectorCraft's own form, which every earlier version reads: a map @@ -395,7 +511,7 @@ mod tests { #[test] fn curvature_keeps_shift_tilde_and_plus_is_one_key() { - let km = template(); + let km = &*template(); let curvature = vc_chord("Shift+~").unwrap(); assert_eq!(curvature, Chord::parse("Shift+`").unwrap()); assert_eq!(lookup(km, &curvature), Some(CommandRef::new("tool:curvature"))); @@ -415,17 +531,21 @@ mod tests { #[test] fn the_default_shortcuts_do_not_conflict() { - assert_eq!(template().conflicts(), vec![]); - assert_eq!(template().problems(), vec![]); - assert_eq!(template().catalog().len(), shortcut_editor::entries().len(), "every entry is bindable"); + // One snapshot: tests running beside this one install and remove plug-ins. + let snapshot = shortcut_editor::snapshot(); + let template = template_for(&snapshot); + assert_eq!(template.conflicts(), vec![]); + assert_eq!(template.problems(), vec![]); + assert_eq!(template.catalog().len(), snapshot.entries.len(), "every entry is bindable"); // A default the bridge can't read would show in the menus but never run. - let unbound: Vec<(&str, &str)> = shortcut_editor::entries() + let unbound: Vec<(&str, &str)> = snapshot + .entries .iter() .filter_map(|e| shortcut_editor::default_of(&e.key).map(|d| (e.key.as_str(), d))) .filter(|(key, d)| { let mut expected: Vec = vc_binding(d).into_iter().collect(); - expected.extend(default_aliases(key).iter().filter_map(|alias| vc_binding(alias))); - expected != template().default_bindings(&command_of(key)) + expected.extend(default_aliases(key, platform()).iter().filter_map(|alias| vc_binding(alias))); + expected != template.default_bindings(&command_in(key, &snapshot.effects)) }) .collect(); assert_eq!(unbound, vec![], "every default text becomes the entry's binding"); @@ -438,10 +558,10 @@ mod tests { let defaults = template().default_bindings(&command); assert_eq!(defaults, vec![vc_binding(plain).unwrap(), vc_binding(shifted).unwrap()], "{key}"); assert_eq!( - shortcut_editor::entry(key).map(|e| e.label.as_str()), + shortcut_editor::entry(key).as_ref().map(|e| e.label.as_str()), Some(if key == "paint.lastColor" { "Apply Last Color" } else { "Apply Last Gradient" }) ); - assert_eq!(effective_with(template(), key).as_deref(), Some(plain)); + assert_eq!(effective_with(&template(), key).as_deref(), Some(plain)); let mut overrides = Overrides::new(); set_override(&mut overrides, key, "Shift+;"); assert_eq!(keymap_with(&overrides).bindings(&command), vec![vc_binding("Shift+;").unwrap()]); @@ -451,13 +571,59 @@ mod tests { assert!(template().conflicts().is_empty()); } + #[test] + fn edit_function_keys_follow_platform_and_reset_restores_both_bindings() { + for p in [Platform::Windows, Platform::Linux, Platform::Mac] { + let km = keymap_for(p, &shortcut_editor::snapshot()); + for (key, primary, alternate) in [("edit.cut", "Cmd+X", "F2"), ("edit.copy", "Cmd+C", "F3"), ("edit.paste", "Cmd+V", "F4")] { + let command = command_of(key); + let mut expected = vec![vc_binding(primary).unwrap()]; + if !p.is_mac() { + expected.push(vc_binding(alternate).unwrap()); + } + assert_eq!(km.default_bindings(&command), expected, "{p:?} {key}"); + assert_eq!(lookup(&km, &vc_chord(primary).unwrap()), Some(command.clone()), "{p:?} {primary}"); + assert_eq!(lookup(&km, &vc_chord(alternate).unwrap()), (!p.is_mac()).then_some(command.clone()), "{p:?} {alternate}"); + assert_eq!(effective_with(&km, key).as_deref(), Some(primary)); + let mut changed = km.clone(); + shortcut_editor::assign_in(&mut changed, key, Some("Ctrl+Alt+Shift+F12"), true).unwrap(); + assert_eq!(changed.bindings(&command), vec![vc_binding("Ctrl+Alt+Shift+F12").unwrap()]); + changed.reset(&command); + assert_eq!(changed.bindings(&command), expected, "{p:?} {key} reset"); + let mut overrides = Overrides::new(); + set_override(&mut overrides, key, "Ctrl+Alt+Shift+F12"); + shortcut_editor::reset_one(&mut overrides, key); + assert_eq!(km.clone().with_overrides(overrides).bindings(&command), expected, "{p:?} {key} use default"); + } + assert!(km.conflicts().is_empty(), "{p:?}"); + assert!(km.problems().is_empty(), "{p:?}"); + + for (alias, edit, primary) in [("F2", "edit.cut", "Cmd+X"), ("F3", "edit.copy", "Cmd+C"), ("F4", "edit.paste", "Cmd+V")] { + let mut saved = Overrides::new(); + set_override(&mut saved, "tool:pen", alias); + let assigned = keymap_for(p, &shortcut_editor::snapshot()).with_overrides(edit_alias_overrides(&saved, p)); + assert_eq!(lookup(&assigned, &vc_chord(alias).unwrap()), Some(command_of("tool:pen")), "{p:?} saved {alias} assignment"); + assert_eq!(lookup(&assigned, &vc_chord(primary).unwrap()), Some(command_of(edit)), "{p:?} primary {edit} binding"); + shortcut_editor::reset_one(&mut saved, "tool:pen"); + let reset = keymap_for(p, &shortcut_editor::snapshot()).with_overrides(edit_alias_overrides(&saved, p)); + assert_eq!(lookup(&reset, &vc_chord(alias).unwrap()), (!p.is_mac()).then_some(command_of(edit)), "{p:?} reset saved {alias}"); + } + let mut unknown = Overrides::new(); + set_override(&mut unknown, "missing.command", "F2"); + let km = keymap_for(p, &shortcut_editor::snapshot()).with_overrides(edit_alias_overrides(&unknown, p)); + assert_eq!(lookup(&km, &vc_chord("F2").unwrap()), (!p.is_mac()).then_some(command_of("edit.cut")), "{p:?} unknown saved entry"); + } + } + /// The chords the operating system takes are refused, and no default uses one. #[test] fn system_shortcuts_are_refused() { + let snapshot = shortcut_editor::snapshot(); for (p, os_chord) in [(Platform::Mac, "Cmd+Tab"), (Platform::Windows, "Alt+F4"), (Platform::Linux, "Ctrl+Alt+F2")] { - let mut km = keymap_for(p); + let mut km = keymap_for(p, &snapshot); assert_eq!((km.conflicts(), km.problems()), (vec![], vec![]), "{p:?}"); - let reserved: Vec = shortcut_editor::entries() + let reserved: Vec = snapshot + .entries .iter() .filter(|e| km.bindings(&command_of(&e.key)).iter().any(|b| km.is_reserved(&b.trigger.chord))) .map(|e| e.key.clone()) @@ -468,7 +634,7 @@ mod tests { // A saved override on a reserved chord keeps the default. let mut ov = Overrides::new(); let _ = ov.insert(stored_key("edit.undo"), vec![os_chord.to_string()]); - let km = keymap_for(p).with_overrides(ov); + let km = keymap_for(p, &snapshot).with_overrides(ov); assert_eq!(km.bindings(&command_of("edit.undo")), km.default_bindings(&command_of("edit.undo")), "{p:?}"); } } @@ -480,7 +646,8 @@ mod tests { assert_eq!(entry_key(&c), "panel:layers"); assert_eq!(entry_of_stored(&stored_key("panel:layers")), "panel:layers"); assert_eq!(stored_key("tool:pen"), "tool:pen"); - let paths = &template().bindable(&c).unwrap().paths; + let template = template(); + let paths = &template.bindable(&c).unwrap().paths; assert_eq!(paths, &vec![vec!["Window".to_string(), "Layers".to_string()]], "import matches panels by name"); } @@ -492,7 +659,8 @@ mod tests { for id in TOGGLES { let label = crate::menus::dynamic_label(&app, id, ""); assert!(label.starts_with("Show ") || label.starts_with("Hide "), "{id}: {label}"); - let listed = template().bindable(&CommandRef::new(*id)); + let template = template(); + let listed = template.bindable(&CommandRef::new(*id)); assert!(listed.is_none_or(|b| b.is_toggle), "{id}"); } } diff --git a/crates/ui-egui/src/layout_migration.rs b/crates/ui-egui/src/layout_migration.rs index e5c6b1f9c..b56b46720 100644 --- a/crates/ui-egui/src/layout_migration.rs +++ b/crates/ui-egui/src/layout_migration.rs @@ -44,10 +44,12 @@ impl UiState { pub fn read_preferences(bytes: &[u8]) -> Option { let mut saved: Value = serde_json::from_slice(bytes).ok()?; let fields = saved.as_object_mut()?; + help_bar_visibility(fields, "status_bar", "help_bar"); let mut intact = recover(fields, Keys::SNAKE); for key in ["custom_workspaces", "workspace_changes"] { if let Some(Value::Array(workspaces)) = fields.get_mut(key) { for workspace in workspaces.iter_mut().filter_map(Value::as_object_mut) { + help_bar_visibility(workspace, "statusBar", "helpBar"); intact &= recover(workspace, Keys::CAMEL); } } @@ -58,6 +60,15 @@ impl UiState { } } +/// Older layouts used status-bar visibility for both bottom bars. Preserve that choice until +/// the layout has an independent Help Bar setting. +fn help_bar_visibility(fields: &mut Map, status: &str, help: &str) { + if !fields.contains_key(help) { + let visible = fields.get(status).and_then(Value::as_bool).unwrap_or(true); + fields.insert(help.into(), Value::Bool(visible)); + } +} + /// The JSON keys of the docking fields: `UiState` is snake_case, [`Workspace`] camelCase. struct Keys { hidden: &'static str, diff --git a/crates/ui-egui/src/layout_migration/tests.rs b/crates/ui-egui/src/layout_migration/tests.rs index d97567eb1..9540f57bf 100644 --- a/crates/ui-egui/src/layout_migration/tests.rs +++ b/crates/ui-egui/src/layout_migration/tests.rs @@ -14,6 +14,26 @@ fn read(saved: &Value) -> ReadPreferences { UiState::read_preferences(&serde_json::to_vec(saved).unwrap()).unwrap() } +#[test] +fn legacy_help_bar_visibility_follows_each_layouts_status_bar() { + let prefs = read(&json!({ + "status_bar": false, + "custom_workspaces": [{"name":"Hidden", "statusBar": false}, {"name":"Shown", "statusBar":true}], + "workspace_changes": [{"name":"Changed", "statusBar":false}] + })); + assert!(!prefs.ui.help_bar); + assert!(!prefs.ui.custom_workspaces[0].help_bar); + assert!(prefs.ui.custom_workspaces[1].help_bar); + assert!(!prefs.ui.workspace_changes[0].help_bar); + assert!(read(&json!({})).ui.help_bar); + for (status, help) in [(true, false), (false, true)] { + let prefs = read(&json!({"status_bar":status, "help_bar":help, + "custom_workspaces":[{"name":"Explicit", "statusBar":status, "helpBar":help}]})); + assert_eq!(prefs.ui.help_bar, help); + assert_eq!(prefs.ui.custom_workspaces[0].help_bar, help); + } +} + /// The dock of a layout in the current shape: the canvas beside a dock at the end edge. fn dock(layout: &Layout) -> &[Column] { assert!(docking::valid(layout), "{layout:?}"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fb8912455..03acb7597 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -94,7 +94,7 @@ use tests::{ mod tests; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc::{Receiver, Sender}; +use std::sync::mpsc::Receiver; use std::sync::{Arc, Mutex}; use serde_json::{Value, json}; @@ -161,6 +161,9 @@ pub struct VectorcraftApp { synthetic_dropped_before: u64, /// Window captures asked for and not answered yet. pending_screenshots: Vec, + /// `ui.pointer` replies waiting for their events and the edit the tool computes on another + /// thread ([`control::Outcome::AfterToolEdit`]), with the numbers of those events. + pointer_replies: Vec, /// Admitted captures whose settled frame has not come yet (issued one at a time). queued_screenshots: Vec, screenshot_token: u64, @@ -195,9 +198,17 @@ pub struct VectorcraftApp { clipboard_publishing: sysclip::Publishing, /// A URL to open through egui next frame (when the host has no `open_url` service). pending_url: Option, + /// `VECTORCRAFT_TITLEBAR` chose this window's frame (Linux and the BSDs), so Preferences › User + /// Interface › System Title Bar and VectorCraft Title Bar on Every Desktop do not apply while + /// it is set. `false` unless the host sets it. + pub titlebar_from_env: bool, /// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag, /// double-click to maximize, caption buttons) and invisible edge zones resize the window. pub custom_titlebar: bool, + /// The window has the desktop's own title bar: the OS or the window manager draws its frame, + /// icon and title. Set by the desktop app when it opens the window; `false` for embedders + /// without a window of their own. + pub desktop_frame: bool, /// Last window title sent to the OS (`ViewportCommand::Title`, see `chrome::sync_window_title`): /// sent again only when it changes, so idle frames don't spam the backend. pub(crate) last_window_title: String, @@ -298,6 +309,9 @@ pub struct VectorcraftApp { /// This frame carries input the control channel injected ([`Self::raw_input_hook`]): it runs /// confined to [`Self::automation_roots`]. synthetic_frame: bool, + /// The plug-in install the approval dialog asks about, kept out of the dialog's fields, which + /// `ui.dialog.set` writes. + pub(crate) pending_plugin_install: Option, /// The pen end the canvas saw last and the tool its eraser end stands in for /// ([`stylus::sync_eraser_tool`]). pub(crate) pen_end: stylus::PenEnd, diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs index e9cfc538a..36811ba4c 100644 --- a/crates/ui-egui/src/menus/entries.rs +++ b/crates/ui-egui/src/menus/entries.rs @@ -33,6 +33,8 @@ pub fn entry<'a>(app: &VectorcraftApp, it: &'a Item) -> Option> { Some(match it { Item::Sep => Entry::Sep, Item::Header(h) => Entry::Header(t(h)), + // A plug-in's submenu shows its name as it is, as its items do. + Item::Sub(label, children) if super::labels::holds_plugin_commands(children) => Entry::Sub(label, children), Item::Sub(label, children) => Entry::Sub(t(label), children), Item::Todo(label, sc) => Entry::Item(Row { command: None, diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 073b1130e..7b3adaffe 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -1,4 +1,4 @@ -use super::state::{SUBJECT_COMMANDS, has_subject, waits_for_ime}; +use super::state::{SUBJECT_COMMANDS, enabled, has_subject, waits_for_ime}; use super::{Value, VectorcraftApp, json}; /// What a menu click runs: items whose label ends with "…" and that carry default params open a @@ -138,6 +138,15 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { if waits_for_ime(app, id) { return; } + // A command chosen while the tool computes an edit on another thread (a Shape Builder merge) + // comes after it: the edit is applied first, so the command's dialog, or the app's own dialog + // (Recolor Artwork), opens on the art as the gesture left it. + let r = app.session.finish_tool_edit(); + crate::canvas::apply_requests(app, r); + // A shortcut or a palette pick on a filter plug-in does nothing while its menu item is dimmed. + if id == "plugin.dialog" && !enabled(app, id) { + return; + } if SUBJECT_COMMANDS.contains(&id) && p.get("ids").is_none_or(|ids| ids.as_array().is_some_and(Vec::is_empty)) && p.get("id").is_none() @@ -152,6 +161,10 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); return; } + // A plug-in command with parameters asks for them, from its menu item or its shortcut. + if p.as_object().is_none_or(|o| o.is_empty()) && crate::dialogs::plugin::open_command(app, id) { + return; + } if open_view_dialog(app, id, &p) || open_object_dialog(app, id, &p) { return; } @@ -267,6 +280,13 @@ fn open_view_dialog(app: &mut VectorcraftApp, id: &str, p: &Value) -> bool { let _ = app.run("ui.paramDialog", json!({"command": id, "label": "New View", "params": {"name": name}})); return true; } + // Composite Fonts…: the document's composite fonts, to edit before OK sets them. + if id == crate::dialogs::composite_fonts::COMMAND && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::composite_fonts::open(app) { + app.status(e); + } + return true; + } // Edit Views…: the saved views in a list, to rename or delete. if id == "view.saved.edit" && p.as_object().is_none_or(|o| o.is_empty()) { if let Err(e) = crate::dialogs::edit_views::open(app) { @@ -347,10 +367,12 @@ fn open_object_dialog(app: &mut VectorcraftApp, id: &str, p: &Value) -> bool { } return true; } - // Export Assets, Remove, Rename and Asset Export Settings with no params, as the command - // palette runs them (#4864): the Asset Export panel, where each one is done. A panel that already - // shows stays open. - if matches!(id, "assets.export" | "assets.remove" | "assets.rename" | "assets.settings.set") && p.as_object().is_none_or(|o| o.is_empty()) { + // Export Assets, Remove, Rename, Asset Export Settings, Export 3D Assets and 3D Export Format + // with no params, as the command palette runs them (#4864): the Asset Export panel, where each + // one is done. A panel that already shows stays open. + if matches!(id, "assets.export" | "assets.remove" | "assets.rename" | "assets.settings.set" | "assets.export3D" | "assets.set3DFormat") + && p.as_object().is_none_or(|o| o.is_empty()) + { if let Err(e) = crate::panel_docking::reveal_panel(app, crate::panels::asset_export::ID) { app.status(e); } diff --git a/crates/ui-egui/src/menus/labels.rs b/crates/ui-egui/src/menus/labels.rs index b8d314346..e26d0bdec 100644 --- a/crates/ui-egui/src/menus/labels.rs +++ b/crates/ui-egui/src/menus/labels.rs @@ -123,7 +123,7 @@ fn command_label(app: &VectorcraftApp, id: &str, label: &str) -> Option /// Does the menu item `id` show a name that is user, file or system data rather than an interface /// label: a recent file, a saved view, a font, a user library or preset, a custom workspace, a -/// plug-in? Such names are shown as they are, never translated (a workspace the user calls +/// plug-in, an SVG filter? Such names are shown as they are, never translated (a workspace the user calls /// "Layers" stays "Layers"). pub(super) fn shows_a_name(id: &str, label: &str) -> bool { const SLOTS: [&str; 8] = [ @@ -138,16 +138,19 @@ 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" | "svgFilter.apply") + || vectorcraft_engine::cmd::plugin::is_command_id(id) || (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) or one of its commands, by the plug-in's +/// own text? 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") + || vectorcraft_engine::cmd::plugin::is_command_id(id) + || 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 @@ -212,11 +215,13 @@ pub(super) fn unlock_item_label(app: &VectorcraftApp, id: &str, p: &Value, lang: } /// The shortcut a menu item shows: its command's (items without params), a panel's for the -/// Window menu's `window.panel {panel}` items, or an effect's for the Effect menu's items. +/// Window menu's `window.panel {panel}` items, an effect's for the Effect menu's items, or a filter +/// plug-in's for Object › Plug-ins' `plugin.dialog {id}` items. pub fn item_shortcut(id: &str, p: &Value) -> Option<&'static str> { match (id, p.get("panel").and_then(Value::as_str)) { ("window.panel", Some(panel)) => crate::shortcut_editor::panel_shortcut(panel), ("effect.dialog" | "effect.apply", _) => p.get("effect").and_then(Value::as_str).and_then(crate::shortcut_editor::effect_shortcut), + ("plugin.dialog", _) => p.get("id").and_then(Value::as_str).and_then(crate::shortcut_editor::filter_shortcut), // The Artboard tool's canvas menu passes the point its Paste goes to. ("edit.paste", _) => shortcut_of(id), // That menu's Cut and Copy: with the tool chosen, the keys run `edit.cut` and `edit.copy`, which do the same. @@ -307,6 +312,11 @@ pub const DYNAMIC_LABELS: &[&str] = &[ "Lock Grid", ]; +/// Is `children` a plug-in's submenu: does it hold a command the plug-in declares? +pub(crate) fn holds_plugin_commands(children: &[Item]) -> bool { + children.iter().any(|c| matches!(c, Item::Cmd(_, id, _) if vectorcraft_engine::cmd::plugin::is_command_id(id))) +} + /// Every English string the menus, the command palette, the panel registry, the toolbar and the /// Preferences dialog can show: what a complete language catalog has to cover. Names that are user /// data (fonts, recent files, saved views, libraries, installed plug-ins), the point sizes and the @@ -335,6 +345,8 @@ pub fn menu_strings() -> std::collections::BTreeSet { Item::Todo(l, _) | Item::Header(l) => { out.insert(l.to_string()); } + // A plug-in's submenu is named after the plug-in. + Item::Sub(_, children) if holds_plugin_commands(children) => walk(children, under, out), Item::Sub(l, children) => { out.insert(l.to_string()); // Type › Font lists the installed families: names, not UI text. diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index f44359973..8821679e8 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -29,7 +29,13 @@ pub(crate) fn opt_bool(p: &Value, key: &str) -> Result, String> { /// `params` has only keys the params description of engine command `id` names, or the parameter /// error naming the one it doesn't (#1442). The UI's own commands aren't checked yet: their /// descriptions haven't been audited against what they read. +/// `file.save` and `file.exportForScreens` take the params of the engine command they run. pub fn check_params(session: &vectorcraft_engine::Session, id: &str, params: &Value) -> Result<(), String> { + let id = match id { + "file.save" => "document.save", + "file.exportForScreens" => "document.exportForScreens", + id => id, + }; if UI_COMMANDS.iter().any(|c| c.0 == id) { return Ok(()); } @@ -107,9 +113,13 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option match s("path") { - Some(path) => io::open_path(app, &path), + Some(path) => match p.get("params").filter(|v| !v.is_null()) { + Some(params) => io::open_path_with(app, &path, params), + None => 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), @@ -270,6 +280,7 @@ fn run_ui_part3(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::paragraph::apply_bullets(app, p), "type.applyNumbers" => crate::panels::paragraph::apply_numbers(app, p), + "ui.bulletsAndNumbering" => crate::dialogs::bullets_numbering::open(app, p), _ => return None, }; Some(r) @@ -448,6 +459,7 @@ fn run_ui_part5(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option flag(&mut app.ui.control_bar), + "window.helpBar" => flag(&mut app.ui.help_bar), // The Control bar's panel menu and gripper: the edge of the window the bar docks to. "window.control.dock" => { let key = "edge"; @@ -477,6 +489,11 @@ fn run_ui_part5(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option flag(&mut app.ui.toolbar_advanced), + "window.toolsBesideCanvas" => opt_bool(p, "on").map(|on| { + let on = on.unwrap_or(!app.ui.tools_beside_canvas); + app.ui.tools_beside_canvas = on; + json!(on) + }), "imageTrace.preview" => opt_bool(p, "on").map(|on| { let on = on.unwrap_or(!app.ui.image_trace_preview); app.ui.image_trace_preview = on; @@ -633,6 +650,7 @@ fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::updates_ui::check(app), + "help.safeMode" => crate::dialogs::safe_mode::open(app), "help.commandPalette" => { app.ui.palette_open = !app.ui.palette_open; app.ui.palette_query.clear(); @@ -799,17 +817,24 @@ fn run_ui_part10(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::reset(app), "attributes.openUrl" => crate::panels::attributes::open_url(app, p), "ui.javascriptFilesDialog" => crate::panels::svg_interactivity::files_dialog(app, p), + "ui.svgFilterDialog" => crate::dialogs::svg_filter::open(app, p), + "ui.importSvgFilter" => crate::dialogs::svg_filter::import_file(app), "ui.spotColors" => crate::dialogs::spot_colors::open(app), - "ui.menuDialog" => match s("command").as_deref() { - Some(c) if crate::dialogs::envelope::opens(c) => crate::dialogs::envelope::open(app, c), - c => match c.and_then(|c| menu_dialog(app, c)) { - Some((kind, fields)) => { - app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); - Ok(Value::Null) - } - None => Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()), - }, - }, + "ui.menuDialog" => { + // As from the menu ([`invoke`]), the edit the tool computes on another thread first. + let r = app.session.finish_tool_edit(); + crate::canvas::apply_requests(app, r); + match s("command").as_deref() { + Some(c) if crate::dialogs::envelope::opens(c) => crate::dialogs::envelope::open(app, c), + c => match c.and_then(|c| menu_dialog(app, c)) { + Some((kind, fields)) => { + app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); + Ok(Value::Null) + } + None => Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()), + }, + } + } "ui.widthPointEdit" => crate::dialogs::width_point::open(app, p), "ui.corners" => crate::dialogs::corners::open(app, p), "ui.hyphenation" => crate::dialogs::hyphenation::open(app), diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index ecaca0c6e..c0a0c04ba 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -59,11 +59,13 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { // a document no item is checked. "view.rotate" => app.view().zip(p.get("angle").and_then(Value::as_f64)).is_some_and(|(view, a)| (view.rotation - a).abs() < 1e-9), "window.control" => app.ui.control_bar, + "window.helpBar" => app.ui.help_bar, "window.control.dock" => { p.get("edge").and_then(Value::as_str) == Some(crate::chrome::CONTROL_BAR_EDGES[usize::from(app.ui.control_bar_bottom)]) } "window.toolbar" => app.ui.toolbar, "window.toolbarAdvanced" => app.ui.toolbar_advanced, + "window.toolsBesideCanvas" => app.ui.tools_beside_canvas, "window.toolbarColumns" => match p.get("toolbar").and_then(Value::as_str) { None => app.ui.toolbar_double, Some(name) => crate::custom_toolbars::find(&app.ui, name).is_some_and(|b| b.double), @@ -134,12 +136,19 @@ fn swapped_out(app: &VectorcraftApp, id: &str) -> bool { enabled(app, "object.envelope.release") != reset } +/// Menu items shown only with Preferences › Type › Show East Asian Options on: Type › Composite +/// Fonts…. +fn east_asian_hidden(app: &VectorcraftApp, id: &str) -> bool { + id == crate::dialogs::composite_fonts::COMMAND && !app.session.prefs.show_east_asian_options +} + /// How the menus show item `id` (params `p`) now: (enabled, checked: `Some` for an item that is on -/// or off), or `None` while they leave it out: an empty slot ([`hidden_when_disabled`]) or an item -/// another stands in for ([`swapped_out`]). +/// or off), or `None` while they leave it out: an empty slot ([`hidden_when_disabled`]), an item +/// another stands in for ([`swapped_out`]) or an East Asian item with those options hidden +/// ([`east_asian_hidden`]). pub fn shown_state(app: &VectorcraftApp, id: &str, p: &Value) -> Option<(bool, Option)> { let en = enabled(app, id) && (id != crate::addons::select::link_status::COMMAND || crate::addons::select::link_status::enabled(app, p)); - if (!en && hidden_when_disabled(id)) || swapped_out(app, id) { + if (!en && hidden_when_disabled(id)) || swapped_out(app, id) || east_asian_hidden(app, id) { return None; } Some((en, checked(app, id, p))) @@ -174,7 +183,7 @@ pub(crate) fn waits_for_ime(app: &VectorcraftApp, id: &str) -> bool { /// targeted layer, group or object, else the selection). Without `ids` (or with an empty list) and /// without a subject they are off, as the panel's buttons are: in menus, in the command palette and /// on their keys. -pub(super) const SUBJECT_COMMANDS: [&str; 2] = ["appearance.addFill", "appearance.addStroke"]; +pub(super) const SUBJECT_COMMANDS: [&str; 3] = ["appearance.addFill", "appearance.addStroke", "svgFilter.apply"]; /// Is there an Appearance panel subject: a targeted layer, group or object, or a selected one? pub(super) fn has_subject(app: &VectorcraftApp) -> bool { @@ -245,7 +254,12 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { let min = crate::panels::pathfinder::minimum_operands(op); crate::panels::pathfinder::operands(app) >= min }), - "file.export.pdf" | "ui.savePdfDialog" | "ui.fileInfoDialog" | "ui.rasterEffectsSettingsDialog" => app.session.active().is_some(), + "file.export.pdf" | "ui.savePdfDialog" | "ui.fileInfoDialog" | "ui.rasterEffectsSettingsDialog" | "ui.svgFilterDialog" => { + app.session.active().is_some() + } + "ui.importSvgFilter" => { + app.session.active().is_some() && crate::picks::can(app, &crate::picks::PickRequest::Open(crate::dialogs::svg_filter::pick())) + } "ui.missingFontsDialog" => !cfg!(target_arch = "wasm32") && app.session.active().is_some(), "ui.findFontsInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(), "ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(), diff --git a/crates/ui-egui/src/menus/submenus.rs b/crates/ui-egui/src/menus/submenus.rs index a878f7f0c..9e8a7ac65 100644 --- a/crates/ui-egui/src/menus/submenus.rs +++ b/crates/ui-egui/src/menus/submenus.rs @@ -108,6 +108,9 @@ pub(super) fn font_items(english_names: bool) -> Vec { items.3.clone() } +/// The catalogue's menu path of the effects in 3D and Materials › 3D (Classic). +const CLASSIC_3D: &[&str] = &["Effect", "3D and Materials", "3D (Classic)"]; + /// The raster effects' submenus of the Effect menu (the Photoshop-style effects, below the /// vector effects): (submenu, the catalogue's menu path of its effects). /// The raster Stylize submenu shares its path with the vector one and lists only the effects at @@ -172,11 +175,15 @@ pub(super) fn effect_menu() -> Vec { out.push(item(e)); continue; } + // Apply SVG Filter…, Import SVG Filter… and the document's filters. + if sub_name == "SVG Filters" { + out.push(sub(sub_name, crate::dialogs::svg_filter::menu_items())); + continue; + } let items = items_at(&["Effect", sub_name], true); if items.is_empty() { let placeholder = match sub_name { "3D and Materials" => vec![todo("Extrude & Bevel…"), todo("Revolve…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")], - "SVG Filters" => vec![todo("Apply SVG Filter…")], _ => continue, }; out.push(sub(sub_name, placeholder)); @@ -190,6 +197,10 @@ pub(super) fn effect_menu() -> Vec { _ => 9, }); items.push(todo("Materials…")); + let classic: Vec = cat.iter().filter(|e| e.menu == CLASSIC_3D).map(item).collect(); + if !classic.is_empty() { + items.push(sub("3D (Classic)", classic)); + } } out.push(sub(sub_name, items)); } @@ -210,7 +221,9 @@ pub(super) fn effect_menu() -> Vec { out.extend(raster); } // Anything not placed above (future effects) still shows up. - let placed = |e: &vectorcraft_effects::EffectInfo| top_level(e) || in_vector_menu(e) || RASTER_MENUS.iter().any(|(_, path)| e.menu == *path); + let placed = |e: &vectorcraft_effects::EffectInfo| { + top_level(e) || in_vector_menu(e) || e.menu == CLASSIC_3D || RASTER_MENUS.iter().any(|(_, path)| e.menu == *path) + }; for e in cat.iter().filter(|e| !placed(e)) { out.push(Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id }))); } diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 3918b9fb1..a0e493561 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -1,6 +1,29 @@ use super::*; use crate::theme; +#[test] +fn help_bar_toggle_is_independent_and_saved_in_workspaces() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + assert!(app.ui.help_bar); + assert_eq!(checked(&app, "window.helpBar", &Value::Null), Some(true)); + assert_eq!(run_ui_command(&mut app, "window.helpBar", &Value::Null).unwrap().unwrap(), json!(false)); + assert!(app.ui.status_bar); + assert_eq!(checked(&app, "window.helpBar", &Value::Null), Some(false)); + let saved = crate::workspaces::capture(&app.ui, "No help"); + let saved: crate::workspaces::Workspace = serde_json::from_value(serde_json::to_value(saved).unwrap()).unwrap(); + app.ui.help_bar = true; + app.ui.status_bar = false; + crate::workspaces::apply(&mut app.ui, &saved); + assert!(!app.ui.help_bar && app.ui.status_bar); + assert_eq!(run_ui_command(&mut app, "window.helpBar", &Value::Null).unwrap().unwrap(), json!(true)); + let legacy: crate::workspaces::Workspace = serde_json::from_value(json!({"name":"Old workspace"})).unwrap(); + assert!(legacy.help_bar); + let restored: crate::state::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); + assert!(restored.help_bar); + let (_, window) = menu_tree().into_iter().find(|(title, _)| *title == "Window").unwrap(); + assert!(window.iter().any(|item| matches!(item, Item::Cmd("Help Bar", "window.helpBar", _)))); +} + /// One headless frame of the in-window menu bar; returns its titles (left to right) as /// (rect, id of the title's popup). fn bar_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(egui::Rect, egui::Id)> { @@ -354,6 +377,22 @@ fn saved_views_store_and_restore_the_view() { assert!(!menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("view.goto2"))); } +/// Effect › 3D and Materials › 3D (Classic) › Extrude & Bevel (Classic)… opens its effect dialog, +/// after Materials…, and nowhere else. +#[test] +fn classic_extrude_bevel_is_in_the_3d_classic_submenu() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let entries = menu_entries(&app); + let classic: Vec<_> = entries.iter().filter(|e| e.params.get("effect").and_then(Value::as_str) == Some("threeD.classicExtrude")).collect(); + assert_eq!(classic.len(), 1, "listed once"); + let e = classic[0]; + assert_eq!(e.path, ["Effect", "3D and Materials", "3D (Classic)"]); + assert_eq!((e.label.as_str(), e.command.as_deref()), ("Extrude & Bevel (Classic)…", Some("effect.dialog"))); + let at = |label: &str| entries.iter().position(|x| x.label == label && x.path.get(1).is_some_and(|p| p == "3D and Materials")); + assert!(at("Materials…").unwrap() < at("Extrude & Bevel (Classic)…").unwrap()); +} + /// A document's 25 views are all listed at the bottom of the View menu, by name, and the last /// one brings back its zoom, centre and rotation after the view changed. #[test] diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 752901c04..fdea9664b 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); @@ -406,6 +407,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("Fit Headline", "text.fitHeadline"), Sep, c("Find Font…", "type.findFont"), + c("Composite Fonts…", crate::dialogs::composite_fonts::COMMAND), sub( "Change Case", vec![ @@ -622,6 +624,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { sub("Workspace", crate::workspaces::menu_items()), Sep, c("Control", "window.control"), + c("Help Bar", "window.helpBar"), c("Contextual Task Bar", "window.taskBar"), c("Tools", "window.toolbar"), sub("Toolbars", crate::custom_toolbars::menu_items()), @@ -641,6 +644,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { panel("Color", "color"), panel("Color Guide", "colorGuide"), panel("Color Themes", "colorThemes"), + panel("Color Wheel", crate::panels::color_wheel::ID), panel("CSS Properties", crate::panels::css_properties::ID), panel("Document Info", "docInfo"), panel("Flattener Preview", crate::panels::flattener_preview::ID), @@ -702,6 +706,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { Sep, c("Search Commands…", "help.commandPalette"), todos("VectorCraft Help…", "F1"), + c("Safe Mode…", "help.safeMode"), Sep, c("Check for Updates…", "help.checkForUpdates"), c("About VectorCraft", "help.about"), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 208abcfeb..930808b64 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -28,7 +28,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.open", "Open…", "Cmd+O", - "{path?} → with a path, document.open's result {index, title, format, warnings, …} (null when a dialog asks first or a library loads)", + "{path?, params?} → with a path, document.open's result {index, title, format, warnings, …} (null when a dialog asks first or a library loads); `params` are the parameters of the importer plug-in that opens the file", + ), + ( + "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) → with a path, document.open's result; a picked file is read off the UI thread on the desktop, {background: true, status}, and opens once the importer returns (file.cancelOpen stops it) | null while the picker is open", ), ( "file.save", @@ -234,6 +240,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{number?: \"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\"|\"none\"} Type › Bullets and Numbering › Apply Numbers: the selected type's paragraphs (or those the Type tool's selection touches) become a numbered list in that number style (text.setFormat {number}); without it, a numbered list keeps its style and other paragraphs are numbered 1, 2, 3 (text.setFormat {list: \"number\"}); \"none\" turns the list off", ), + ( + "ui.bulletsAndNumbering", + "Bullets and Numbering…", + "", + "{tab?: \"bullets\"|\"numbers\"} open Bullets and Numbering (More Options in the Paragraph panel's Bullets and Numbered List presets) on the paragraphs the Paragraph panel acts on, filled in with the values it shows, on that tab (without it, Numbers when the paragraphs are a numbered list, else Bullets) (dialog `bulletsAndNumbering`: tab (bullets|numbers), list (none|bullet|number: the kind of the preset picked), bullet (dot|dash|circle|square), number (arabic|upperLetters|lowerLetters|upperRoman|lowerRoman|twoDigits|threeDigits), listAlign (left|right), listLevel (1..9; 1 for paragraphs in no list), leftIndent, firstLineIndent (pt)): the type shows the dialog's values while it is open, OK runs text.setFormat with the values that changed (and the indents it shows when a preset puts the paragraphs in a list), as one undo step, and Cancel puts the type back; a preset picked for paragraphs in no list whose indents are 0 fills in 18 and -18 pt; an error with no type selected or an unknown tab", + ), ( "type.convertListToText", "Convert to Text", @@ -447,6 +459,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ), ("view.rotateReset", "Reset Rotate View", "Cmd+Shift+1", "{}"), ("window.control", "Control", "", "{}"), + ("window.helpBar", "Help Bar", "", "{} toggle the tool-help strip independently of the status bar; returns the new visibility"), ( "window.control.dock", "Dock Control Bar", @@ -461,6 +474,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "{double?: bool, toolbar?: name} show the toolbar's tools (with `toolbar`, that toolbar made with New Toolbar's) in two columns (true), one (false) or toggle (omitted), as the double arrow at the top of the toolbar does; returns the new state", ), ("window.toolbarAdvanced", "Toolbar: Advanced / Basic", "", "{}"), + ( + "window.toolsBesideCanvas", + "Keep Tools Beside the Canvas", + "", + "{on?: bool} dock the Tools panel between the left-side panels and the canvas (true) or at the window's left edge (false, the default), or toggle (omitted or null; any other value is an error); a floating Tools panel stays floating and docks at the chosen place when it is docked again. Kept with the workspace; a workspace saved without it docks Tools at the window's edge. Returns the new state", + ), ( "window.floatTools", "Float Tool Group", @@ -596,7 +615,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.panel.dock", "Dock Panel", "", - "{panel: id or \"tools\", anchor?: id, zone?: tab|center|left|right|top|bottom|above|below, before?: id, group?: bool, column?: bool or n, side?: Start|End, onto?: id, above?: id, below?: id, icon?: bool, collapsed?: bool} dock a panel (with `group`, its whole group): with no destination a floating panel goes back where it floated from (else joins the first dock group) and a hidden one shows where it was; with `anchor` (or `onto`) in the group holding that panel, `zone` as for window.panel.move; with `side`, as a new innermost column of that dock (`column: true` then moves its whole dock column or floating window); `column: n` with no other destination docks its group, or the whole floating window it floats in, as a new dock column at that place (1: next to the icon column, counting inwards; `true`: innermost); `icon: true` puts it in a row of its own at the end of the icon column; `above` / `below` as a group of its own above or below the group holding that panel. `collapsed` then minimizes or restores its group. \"tools\" docks the Tools panel at the left edge of the window. Returns {layout, closed, floating}", + "{panel: id or \"tools\", anchor?: id, zone?: tab|center|left|right|top|bottom|above|below, before?: id, group?: bool, column?: bool or n, side?: Start|End, onto?: id, above?: id, below?: id, icon?: bool, collapsed?: bool} dock a panel (with `group`, its whole group): with no destination a floating panel goes back where it floated from (else joins the first dock group) and a hidden one shows where it was; with `anchor` (or `onto`) in the group holding that panel, `zone` as for window.panel.move; with `side`, as a new innermost column of that dock (`column: true` then moves its whole dock column or floating window); `column: n` with no other destination docks its group, or the whole floating window it floats in, as a new dock column at that place (1: next to the icon column, counting inwards; `true`: innermost); `icon: true` puts it in a row of its own at the end of the icon column; `above` / `below` as a group of its own above or below the group holding that panel. `collapsed` then minimizes or restores its group. \"tools\" docks the Tools panel at the left edge of the window, or beside the canvas inside any left-side dock while window.toolsBesideCanvas is on; with side: right (or End) on the right, between the canvas and the panels, as dropping it there or on the window's right edge does (default: the side it docked at last; left or Start docks it on the left), returning {panel, floating, pos, side}. Returns {layout, closed, floating}", ), ( "window.collapseDock", @@ -712,6 +731,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("help.about", "About VectorCraft", "", "{}"), ("help.checkForUpdates", "Check for Updates…", "", "{}"), ("help.commandPalette", "Search Commands…", "Cmd+Shift+/", "{}"), + ( + "help.safeMode", + "Safe Mode…", + "", + "{} open the Safe Mode dialog (dialog `safeMode`): whether VectorCraft is in Safe Mode, and the disabled plug-ins, each with Enable on Re-launch (engine: plugin.setEnabled). Closing it leaves Safe Mode as it is; Safe Mode is chosen at launch (--safe-mode, or the choice after a launch that stopped while a plug-in was loading, dialog `pluginStopped`)", + ), ("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"), ( "ui.swatchOptions", @@ -1070,6 +1095,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} open JavaScript Files for the active document (dialog `javascriptFiles`, fields `file`: a path or URL and `selected`: a linked file; the SVG Interactivity panel's Link JavaScript Files button): its list shows svgInteractivity.info's files, Add runs svgInteractivity.addFile with file and Remove runs svgInteractivity.removeFile with selected", ), + ( + "ui.svgFilterDialog", + "Apply SVG Filter…", + "", + "{index?, item?} open Apply SVG Filter for the active document (dialog `svgFilter`, fields `selected`: a filter's name, `code`: the code being edited, `editing`: \"\", \"new\" or the name of the filter being edited; with index, for the applied svg.filter effect at index of item, as effect.dialog opens it): its list shows svgFilter.list's filters; OK runs svgFilter.apply with selected, or effect.setParams on the applied effect; New and Edit open the code, Save runs svgFilter.define, Delete runs svgFilter.delete and Import… svgFilter.import with a picked SVG file", + ), + ( + "ui.importSvgFilter", + "Import SVG Filter…", + "", + "{} pick an SVG file and add its filters to the active document's SVG filters (svgFilter.import) → svgFilter.import's result", + ), ( "ui.spotColors", "Spot Colors…", @@ -1239,7 +1276,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.dxfOptionsDialog", "DXF Options Dialog", "", - "{path?, useArtboards?, range?, …document.exportDxf options} open DXF Options (dialog `dxfOptions`: fields = these options over the ones used last); OK checks them, remembers them and writes path (else asks). Export As… → DXF opens it too", + "{path?, useArtboards?, range?, …document.exportDxf options} open DXF Options (dialog `dxfOptions`: fields = these options over the ones used last, a dxfPreset's options under them; the dxfPreset field shows cnc while every field it sets matches, else custom); OK checks them, remembers them and writes path (else asks). Export As… → DXF opens it too", ), ( "links.editOriginal", @@ -1359,7 +1396,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::panels::actions::LOAD_SET, "Load Actions…", "", - "{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused; the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}", + "{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused; the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file; the other steps, every step with no label and every step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \"not an action set file\"", ), ( crate::panels::actions::DELETE_SETS, @@ -1451,7 +1488,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.installPlugin", "Install Plug-in…", "", - "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), allowHeavy?: bool (the user agreed to its heavy budget), grant?: [permission] (the permissions the user granted it), replace?: bool, hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, and so does a filter that asks for a heavy budget: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing → plugin.install's result | {pending: \"confirm\"} while it asks", + "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK when it asks about permissions or a heavy budget: ui.dialog.confirm, answer_dialog and synthetic input are refused → plugin.install's result | {pending: \"pluginInstall\"} while it asks about permissions or a heavy budget | {pending: \"confirm\"} while it asks only about replacing", ), ( "plugin.cancelRuns", diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs index 9191509ae..99e0e9d6b 100644 --- a/crates/ui-egui/src/opening.rs +++ b/crates/ui-egui/src/opening.rs @@ -11,6 +11,7 @@ use std::sync::Arc; use std::sync::mpsc::{Receiver, TryRecvError, channel}; use serde_json::{Value, json}; +use vectorcraft_engine::cancel::{self, CancelToken}; use vectorcraft_engine::cmd::fileio::{self, ReadFile}; use vectorcraft_engine::file_access::ScopeHandle; @@ -30,6 +31,8 @@ pub struct Pending { /// The automation roots in force where the open started: the document is installed under /// them too (its links are read as it opens). scope: ScopeHandle, + /// Raised by Cancel, on the thread reading the file: an importer plug-in stops. + token: CancelToken, /// Continue in Background: the progress window no longer shows it. pub hidden: bool, } @@ -65,6 +68,11 @@ pub fn available() -> bool { /// own ([`fileio::read_for_open`]) → `{background: true, status}`, or the bytes back when no thread /// could start (the caller opens the file at once). pub fn start(app: &mut VectorcraftApp, path: &str, bytes: Vec) -> Result> { + start_with(app, path, bytes, Value::Null) +} + +/// [`start`] with the `document.open` options in `params` (`importer` and its `params`). +pub fn start_with(app: &mut VectorcraftApp, path: &str, bytes: Vec, params: Value) -> Result> { let (tx, done) = channel(); let bytes = Arc::new(bytes); let (name, file) = (path.to_string(), bytes.clone()); @@ -74,11 +82,15 @@ pub fn start(app: &mut VectorcraftApp, path: &str, bytes: Vec) -> Result) -> Result Value { let cancelled = std::mem::take(&mut app.opening.pending); for p in &cancelled { + p.token.cancel(); // The status bar no longer says it is opening. if app.ui.status == format!("{}…", p.label) { app.ui.status.clear(); diff --git a/crates/ui-egui/src/opening/tests.rs b/crates/ui-egui/src/opening/tests.rs index 1f5a6cbd1..ef62509bf 100644 --- a/crates/ui-egui/src/opening/tests.rs +++ b/crates/ui-egui/src/opening/tests.rs @@ -50,6 +50,74 @@ fn open_ready(app: &mut VectorcraftApp) { poll(app); } +fn install_importer(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 file for the importers below, in a folder of its own. +fn importer_file(tag: &str) -> (std::path::PathBuf, String) { + let dir = std::env::temp_dir().join(format!("vc-ui-opening-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("a.tob"); + std::fs::write(&file, "not json").unwrap(); + let file = file.to_string_lossy().into_owned(); + (dir, file) +} + +/// Open With off the UI thread: nothing opens until the importer has read the file with the +/// parameters it was given; then the document opens and joins Open Recent, and a failure is +/// reported in a dialog. +#[test] +fn open_with_runs_the_importer_off_the_ui_thread() { + use vectorcraft_plugins::wat::{self as tpl, Given}; + let rect = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":true,"anchors":[{"p":[10,20]},{"p":[110,20]},{"p":[110,70]},{"p":[10,70]}]}]}}]}"#; + let (dir, file) = importer_file("with"); + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#; + install_importer(&mut app, &tpl::when_given(&tpl::importer("test.opening.with", r#"["tob"]"#, params), Given::Params, r#""density":7"#, rect)); + + let open = |density: i64| json!({"importer": "plugin.test.opening.with", "path": file, "params": {"density": density}}); + let r = crate::io::open_with_on(&mut app, &open(7), true).unwrap(); + assert_eq!(r["background"], true, "{r}"); + assert!(app.session.documents().is_empty(), "nothing opens before the importer has read the file"); + assert!(app.ui.recent_files.is_empty()); + open_ready(&mut app); + assert_eq!(app.session.documents().len(), 1); + assert_eq!(app.ui.recent_files, std::slice::from_ref(&file)); + + // Density 4 gives the file back, which isn't JSON. + app.ui.recent_files.clear(); + crate::io::open_with_on(&mut app, &open(4), true).unwrap(); + open_ready(&mut app); + assert_eq!(app.session.documents().len(), 1); + assert!(app.ui.status.contains("Couldn't open a.tob"), "{}", app.ui.status); + assert!(app.ui.dialog.is_some(), "the failure is reported in a dialog"); + assert!(app.ui.recent_files.is_empty()); + app.run("plugin.remove", json!({"id": "test.opening.with"})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Cancel stops an importer that never returns, on the thread reading the file. +#[test] +fn cancel_stops_a_running_importer() { + use vectorcraft_plugins::wat as tpl; + let (dir, file) = importer_file("cancel"); + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + install_importer(&mut app, &tpl::module(&tpl::importer("test.opening.spins", r#"["tob"]"#, "{}"), "(loop $l (br $l)) (i64.const 0)", "", 1)); + crate::io::open_with_on(&mut app, &json!({"importer": "plugin.test.opening.spins", "path": file}), true).unwrap(); + let done = std::mem::replace(&mut app.opening.pending[0].done, std::sync::mpsc::channel().1); + assert_eq!(cancel_all(&mut app)["cancelled"], 1); + let r = done.recv().unwrap(); + assert!(r.as_ref().err().is_some_and(|e| e.contains("cancelled")), "{:?}", r.err()); + assert!(app.session.documents().is_empty()); + app.run("plugin.remove", json!({"id": "test.opening.spins"})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + /// #1694: a file that finishes opening while a dialog is up waits for it to close, so the /// dialog's document stays the active one. #[test] diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index fda2e1f7f..e895bef6c 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -11,12 +11,14 @@ use crate::{VectorcraftApp, menus}; pub fn items() -> Vec<(String, String, String)> { let mut items = vec![]; // Aliases kept for older scripts run a command that is listed already. - for c in vectorcraft_engine::command_specs().iter().filter(|c| !vectorcraft_engine::cmd::is_alias(c.id)) { + let plugin_commands = vectorcraft_engine::cmd::plugin::command_specs(); + for c in vectorcraft_engine::command_specs().iter().filter(|c| !vectorcraft_engine::cmd::is_alias(c.id)).chain(plugin_commands) { let path = c.menu.join(" › "); let label = if path.is_empty() { c.label.to_string() } else { format!("{path} › {}", c.label) }; items.push((label, c.id.to_string(), menus::shortcut_of(c.id).unwrap_or("").to_string())); } - for c in menus::UI_COMMANDS { + // `plugin.dialog` runs nothing without an id: [`build`] lists each filter instead. + for c in menus::UI_COMMANDS.iter().filter(|c| c.0 != "plugin.dialog") { items.push((c.1.to_string(), c.0.to_string(), menus::shortcut_of(c.0).unwrap_or("").to_string())); } for tool in vectorcraft_tools::catalog::all_tools() { @@ -32,7 +34,7 @@ fn shown_label(lang: crate::i18n::Lang, label: &str) -> String { } /// One palette entry, with what it shows and what a query matches worked out once. -struct Entry { +pub(crate) struct Entry { id: String, shortcut: String, /// The label in the UI language. @@ -59,20 +61,47 @@ fn entries(ctx: &egui::Context, lang: crate::i18n::Lang) -> Arc> { { return v; } - let built: Arc> = Arc::new( - items() - .into_iter() - .map(|(label, id, shortcut)| { - let shown = shown_label(lang, &label); - let haystack = format!("{}\n{}\n{}", label.to_lowercase(), shown.to_lowercase(), id.to_lowercase()); - Entry { id, shortcut, shown, haystack } - }) - .collect(), - ); + let built: Arc> = Arc::new(build(lang)); ctx.data_mut(|d| d.insert_temp::(id, (key, built.clone()))); built } +/// The palette's entries in `lang`: [`items`], then one per filter plug-in's Object › Plug-ins +/// item (`plugin:`), whose name is not translated. +fn build(lang: crate::i18n::Lang) -> Vec { + let entry = |label: String, shown: String, id: String, shortcut: String| { + let haystack = format!("{}\n{}\n{}", label.to_lowercase(), shown.to_lowercase(), id.to_lowercase()); + Entry { id, shortcut, shown, haystack } + }; + let mut out: Vec = items() + .into_iter() + .map(|(label, id, shortcut)| { + let shown = shown_label(lang, &label); + entry(label, shown, id, shortcut) + }) + .collect(); + out.extend(crate::shortcut_editor::filter_items().iter().map(|f| { + let shortcut = crate::shortcut_editor::filter_shortcut(&f.id).unwrap_or("").to_string(); + let label = format!("{} › {}", f.group, f.label); + let shown = format!("{} › {}", shown_label(lang, &f.group), f.label); + entry(label, shown, format!("plugin:{}", f.id), shortcut) + })); + out +} + +/// Runs the palette result `id`: selects a tool, or invokes a command with the params its entry +/// stands for. +pub(crate) fn run_pick(app: &mut VectorcraftApp, id: &str) { + if let Some(tool) = id.strip_prefix("tool:") { + app.select_tool(tool); + } else if id.starts_with("plugin:") { + let command = crate::keymap::command_of(id); + menus::invoke(app, &command.id, command.params); + } else { + menus::invoke(app, id, json!({})); + } +} + pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if !app.ui.palette_open { return; @@ -137,11 +166,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { ctx.data_mut(|d| d.insert_temp(sel_id, (sel, q))); if let Some(id) = run { app.ui.palette_open = false; - if let Some(tool) = id.strip_prefix("tool:") { - app.select_tool(tool); - } else { - menus::invoke(app, &id, json!({})); - } + run_pick(app, &id); } } @@ -222,7 +247,7 @@ mod tests { /// Asset Export panel instead of failing on the missing params or exporting nowhere. #[test] fn a_pick_of_an_asset_export_command_opens_its_panel() { - let ids = ["assets.export", "assets.remove", "assets.rename", "assets.settings.set"]; + let ids = ["assets.export", "assets.remove", "assets.rename", "assets.settings.set", "assets.export3D", "assets.set3DFormat"]; for id in ids { assert!(items().iter().any(|(_, item, _)| item == id), "{id} is in the palette"); let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); @@ -264,4 +289,39 @@ mod tests { app.ui.palette_query = "zoom in".into(); assert_eq!(frame(&mut app, vec![]), 0); } + + /// Each filter plug-in has one result, also when two share a name: its menu path translates, + /// the plug-in's name does not, and a pick runs that filter (a dialog for one with parameters, + /// nothing with no document open). + #[test] + fn each_filter_plugin_is_a_result_of_its_own() { + use vectorcraft_plugins::wat as tpl; + let (one, two) = ("test.UI.palette_filter-one", "test.UI.palette_filter-two"); + let (key_one, key_two) = (format!("plugin:{one}"), format!("plugin:{two}")); + let install = |id: &str, params: &str| { + let manifest = format!(r#"{{"id":"{id}","name":"Group","version":"1","kind":"filter","params":{params}}}"#); + vectorcraft_plugins::registry::install_bytes(&wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap()).unwrap(); + }; + install(one, "{}"); + install(two, r#"{"wiggle":{"type":"bool"}}"#); + let zh = Lang::from_code("zh-hant").unwrap(); + let built = build(zh); + let of = |key: &str| built.iter().filter(|e| e.id == key).collect::>(); + assert_eq!((of(&key_one).len(), of(&key_two).len()), (1, 1)); + assert!(built.iter().all(|e| e.id != "plugin.dialog"), "no result without a filter's id"); + let path = format!("{} › {}", crate::i18n::tr(zh, "Object"), crate::i18n::tr(zh, "Plug-ins")); + assert_eq!(of(&key_two)[0].shown, format!("{path} › Group…")); + assert_eq!(of(&key_one)[0].shown, format!("{path} › Group")); + assert!(of(&key_two)[0].haystack.contains("object › plug-ins › group…")); + + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + run_pick(&mut app, &key_two); + assert!(app.ui.dialog.is_none(), "no document: the dimmed item's pick does nothing"); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + run_pick(&mut app, &key_two); + let d = app.ui.dialog.take().expect("the filter's dialog opens"); + assert_eq!((d.kind.as_str(), d.str("__plugin")), ("plugin", two.to_string())); + vectorcraft_plugins::registry::remove(one); + vectorcraft_plugins::registry::remove(two); + } } diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 73d981000..a74f47b6a 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -1095,7 +1095,7 @@ fn known(action: &Action) -> bool { } } -/// Draw the canvas and the panels in the space left by the bars and the Tools panel, and apply +/// Draw the canvas, its docked Tools panel and the other panels below the bars, and apply /// what the user did to them. pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let area = area_id(); @@ -1136,7 +1136,8 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { } let modal = modal_open(app, &ctx); // Palette windows are OS windows of their own, held above the app's window while it is active, - // so a dialog, a menu or a tool flyout drawn in the main window would be covered by them. + // so a dialog, a menu, a tool flyout or the All Tools drawer drawn in the main window would be + // covered by them. #[cfg(not(target_arch = "wasm32"))] palette_window_levels(&ctx, &layout, modal, egui::Popup::is_any_open(&ctx) || crate::menus::menu_open(&ctx) || crate::toolbar::flyout_open(&ctx)); let mut blocked = Vec::new(); @@ -1203,8 +1204,8 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { } } -/// Whether a modal is open: a dialog, About, the command palette, a native file dialog, or a -/// window behind the modal backdrop last frame (export progress, plug-in runs). Floating panels +/// Whether a modal is open: a dialog, About, the command palette, a native file dialog, an update +/// dialog, or a window behind the modal backdrop last frame (export progress, plug-in runs). Floating panels /// in OS windows of their own take no input meanwhile, as the main window behind it takes none. /// The window level and visibility a palette window takes: hidden under a modal dialog (a click /// must not bring it back over the dialog), at the normal level under a menu or another popup drawn @@ -1259,6 +1260,7 @@ pub(crate) fn modal_open(app: &VectorcraftApp, ctx: &egui::Context) -> bool { || app.ui.about || app.ui.palette_open || app.file_dialog_open() + || crate::updates_ui::blocks_input(app) || ctx.memory(|m| m.areas().is_visible(&crate::dialogs::modal::backdrop())) } @@ -1346,6 +1348,7 @@ struct PanelContent<'a> { impl craft_ui::docking::DockContent for PanelContent<'_> { fn body(&mut self, ui: &mut egui::Ui, id: &String) { if id == CANVAS { + crate::toolbar::show_beside_canvas(self.app, ui); self.app.canvas_area(ui); return; } @@ -1389,7 +1392,8 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } fn limits(&self, id: &String) -> craft_ui::docking::PanelLimits { - let min = if id == CANVAS { egui::vec2(200.0, 120.0) } else { egui::vec2(PANEL_MIN_WIDTH, 80.0) }; + let min = + if id == CANVAS { egui::vec2(200.0 + crate::toolbar::docked_width(&self.app.ui), 120.0) } else { egui::vec2(PANEL_MIN_WIDTH, 80.0) }; craft_ui::docking::PanelLimits { min, ..Default::default() } } @@ -1415,13 +1419,53 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { crate::panels::panel_menu_items(self.app, ui, id); } - fn group_header_width(&self, _panels: &[String], _active: &String, _floating: bool, _strip_width: f32) -> f32 { - 28.0 + fn group_header_width(&self, panels: &[String], _active: &String, floating: bool, _strip_width: f32) -> f32 { + let flyout = self.app.ui.open_panel.as_ref().is_some_and(|panel| panels.contains(panel)); + if floating || flyout { 52.0 } else { 28.0 } } - fn group_header(&mut self, ui: &mut egui::Ui, _panels: &[String], active: &String, _floating: bool) { - let rect = egui::Rect::from_center_size(ui.max_rect().center(), egui::vec2(16.0, 16.0)); - crate::panels::panel_menu(self.app, ui, active, rect); + fn group_header(&mut self, ui: &mut egui::Ui, panels: &[String], active: &String, floating: bool) { + let header = ui.max_rect(); + let flyout = self.app.ui.open_panel.clone().filter(|panel| panels.contains(panel)); + let menu_cell = if floating || flyout.is_some() { + let split = (header.right() - 28.0).max(header.left()); + let cell = egui::Rect::from_min_max(header.min, egui::pos2(split, header.bottom())); + if cell.is_positive() { + let rect = egui::Rect::from_center_size(cell.center(), egui::vec2(24.0_f32.min(cell.width()), 24.0_f32.min(cell.height()))); + let tokens = crate::theme::Tokens::get(ui.ctx()); + let response = ui.interact(rect, ui.scope_id().with(("collapse-to-icons", active)), egui::Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Collapse to Icons"))); + let color = if response.hovered() || response.has_focus() { tokens.text_strong } else { tokens.text_dim }; + ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())).text( + rect.center(), + egui::Align2::CENTER_CENTER, + "»", + egui::TextStyle::Button.resolve(ui.style()), + color, + ); + if response.on_hover_text(tl!("Collapse to Icons")).clicked() { + let result = if let Some(panel) = flyout { + // The opening icon can differ from the currently active tab. + self.app.run("window.panel", json!({"panel": panel})) + } else { + self.app.run("window.panel.dock", json!({"panel": active, "icon": true, "group": true})) + }; + match result { + // Dock commands reveal their destination. This control leaves the group collapsed. + Ok(_) => self.app.ui.open_panel = None, + Err(error) => self.app.ui.status = error, + } + } + } + egui::Rect::from_min_max(egui::pos2(split, header.top()), header.max) + } else { + header + }; + if menu_cell.is_positive() { + let rect = + egui::Rect::from_center_size(menu_cell.center(), egui::vec2(16.0_f32.min(menu_cell.width()), 16.0_f32.min(menu_cell.height()))); + crate::panels::panel_menu(self.app, ui, active, rect); + } } } diff --git a/crates/ui-egui/src/panel_docking/native_tests.rs b/crates/ui-egui/src/panel_docking/native_tests.rs index 784b86fcf..abb7f2a63 100644 --- a/crates/ui-egui/src/panel_docking/native_tests.rs +++ b/crates/ui-egui/src/panel_docking/native_tests.rs @@ -25,6 +25,7 @@ struct Window { frames: usize, /// The texts its last frame drew, with their rects in the window. texts: Vec<(String, Rect)>, + accessibility: Vec, } struct Host { @@ -82,7 +83,14 @@ impl Host { let mut host = host.borrow_mut(); let builder = &viewport.builder; let rect = Rect::from_min_size(builder.position.unwrap_or_default(), builder.inner_size.unwrap_or(vec2(300.0, 200.0))); - host.windows.entry(id).or_insert_with(|| Window { rect, events: Vec::new(), dropped: Vec::new(), frames: 0, texts: Vec::new() }); + host.windows.entry(id).or_insert_with(|| Window { + rect, + events: Vec::new(), + dropped: Vec::new(), + frames: 0, + texts: Vec::new(), + accessibility: Vec::new(), + }); let viewports = host.infos(); let (time, focused) = (host.time, host.focus == id); let Some(window) = host.windows.get_mut(&id) else { return }; @@ -171,10 +179,13 @@ impl Host { } let mut shown = Vec::new(); full.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); + let accessibility = + full.platform_output.accesskit_update.take().map(|update| update.nodes.into_iter().map(|(_, node)| node).collect()).unwrap_or_default(); let mut host = host.borrow_mut(); host.child_layer_order.insert(id, layer_order); if let Some(window) = host.windows.get_mut(&id) { window.texts = shown; + window.accessibility = accessibility; } host.apply(&full); } @@ -349,6 +360,22 @@ fn open_button_menu(h: &mut Harness, viewport: ViewportId, side: f32, rank: impl menu } +/// Click a named button, independent of other controls sharing its size or position. +fn open_named_button_menu(h: &mut Harness, viewport: ViewportId, name: &str) -> Pos2 { + let buttons: Vec = h.host.borrow().windows[&viewport] + .accessibility + .iter() + .filter(|node| node.role() == egui::accesskit::Role::Button && node.label() == Some(name)) + .filter_map(|node| node.bounds()) + .map(|bounds| Rect::from_min_max(pos2(bounds.x0 as f32, bounds.y0 as f32), pos2(bounds.x1 as f32, bounds.y1 as f32))) + .collect(); + assert_eq!(buttons.len(), 1, "one visible button named {name}"); + let at = buttons[0].center(); + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([Event::PointerMoved(at), button(at, true), button(at, false)]); + h.frames(8); + at +} + /// A click on the ≡ button in the OS window `viewport`, the 16 pt square at the right end of the /// panel's tab strip. Returns where the button is. fn open_panel_menu(h: &mut Harness, viewport: ViewportId) -> Pos2 { @@ -421,14 +448,18 @@ fn the_library_submenu_scrolls_inside_a_short_native_window() { assert_submenu_reaches_its_end(&mut h, "swatches", 240.0, "Open Swatch Library", "Default Swatches", "Other Library…"); } -/// The Layers panel's Filter popup, under the topmost 24 pt button: the wheel brings its last row +/// The Layers panel's named Filter button opens its popup: the wheel brings its last row /// into a short window. #[test] fn the_layers_filter_popup_scrolls_inside_a_short_native_window() { let mut h = Harness::new(); + h.ctx.enable_accesskit(); let viewport = h.float("layers"); resize_window(&mut h, viewport, 160.0); - open_button_menu(&mut h, viewport, 24.0, |at| -at.y); + // The header's collapse control is also 24 pt square and sits above Filter. + assert!(text_rect(&h, viewport, "»").is_some(), "keep the competing header control in this regression"); + open_named_button_menu(&mut h, viewport, "Filter"); + assert!(floating_window(&layout_of(&h.app.ui), "layers").is_some(), "Filter must not collapse the panel"); let over = text_rect(&h, viewport, "By Object").expect("the popup's By Object heading"); assert!(text_rect(&h, viewport, "Hidden").is_none_or(|end| end.bottom() > 160.0), "the popup opens at the top of its list"); wheel_down(&mut h, viewport, over.center(), 5000.0); @@ -611,6 +642,33 @@ fn a_modal_blocks_native_windows_and_takes_the_keyboard_from_them() { assert_eq!(h.app.session.tool_id(), "pen", "the window takes keys again once the modal closes"); } +/// The update prompt and the result window of a check block a floating panel's OS window as any +/// modal does. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn the_update_dialogs_block_native_windows_and_take_the_keyboard_from_them() { + type Open = fn(&mut Harness); + let opens: [(&str, Open); 2] = [ + ("the prompt", |h| crate::updates_ui::open_prompt_for_test(&mut h.app)), + ("the result window", |h| crate::updates_ui::open_result_window_for_test(&mut h.app, &h.ctx)), + ]; + for (name, open) in opens { + let mut h = Harness::new(); + let viewport = h.float("swatches"); + open(&mut h); + h.frames(6); + let focus_commands = h.host.borrow().commands.iter().filter(|(id, c)| *id == ViewportId::ROOT && *c == ViewportCommand::Focus).count(); + assert_eq!(focus_commands, 1, "{name}: the main window gets the keyboard"); + h.key_in(viewport, Key::P); + assert_eq!(h.app.session.tool_id(), "selection", "{name}: a key typed in the blocked window does nothing"); + + crate::updates_ui::close_dialogs_for_test(&mut h.app); + h.frames(3); + h.key_in(viewport, Key::P); + assert_eq!(h.app.session.tool_id(), "pen", "{name}: the window takes keys again once the dialog closes"); + } +} + /// The level and visibility a palette window takes under a dialog or a menu, and otherwise: a /// palette stays out of the way of anything drawn in the main window. #[test] @@ -750,7 +808,38 @@ fn a_flyout_or_drawer_of_a_hidden_toolbar_leaves_the_palette_windows_shown() { h.app.run("window.panels", json!({})).unwrap(); h.frames(1); assert!(!h.app.ui.toolbar && h.app.ui.flyout.is_some(), "the flyout is left set with the Tools panel hidden"); - assert_eq!(take_palette_commands(&h, viewport).0.last(), Some(&true), "shown again: the flyout does not draw"); + assert_eq!(take_palette_commands(&h, viewport).0, [true], "shown again: the flyout does not draw"); +} + +/// A strip torn off a flyout and a toolbar made with New Toolbar stay on screen like panels, so the +/// palette windows stay shown beside them. +#[test] +fn a_torn_off_strip_or_a_new_toolbar_leaves_the_palette_windows_shown() { + let mut h = Harness::new(); + let viewport = h.float("navigator"); + h.host.borrow_mut().focus = ViewportId::ROOT; + h.frames(2); + take_palette_commands(&h, viewport); + // The commands go only on a change, so the drawer opening and closing shows the palette windows + // were on screen before the strip and the toolbar. + h.app.run("window.editToolbar", json!({"open": true})).unwrap(); + h.frames(2); + assert_eq!(take_palette_commands(&h, viewport).0, [false], "hidden under the drawer"); + h.app.run("window.editToolbar", json!({"open": false})).unwrap(); + h.frames(2); + assert_eq!(take_palette_commands(&h, viewport).0, [true], "shown when the drawer closes"); + h.app.run("window.floatTools", json!({"tool": "pen"})).unwrap(); + h.app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + h.frames(2); + let strips: Vec<&str> = h.app.ui.floating_flyouts.iter().filter_map(|strip| strip.tools.first().map(String::as_str)).collect(); + assert_eq!(strips, ["pen"], "the Pen strip floats"); + let toolbars: Vec<(&str, bool)> = h.app.ui.custom_toolbars.iter().map(|toolbar| (toolbar.name.as_str(), toolbar.shown)).collect(); + assert_eq!(toolbars, [("Mine", true)], "the new toolbar shows"); + assert_eq!(h.app.ui.flyout, None, "no flyout is open"); + assert_eq!(take_palette_commands(&h, viewport), (vec![], vec![]), "the palette windows are left as they were"); + h.app.run("window.editToolbar", json!({"open": true})).unwrap(); + h.frames(2); + assert_eq!(take_palette_commands(&h, viewport).0, [false], "still shown: the drawer hides them again"); } /// The frame in which a click elsewhere closes a flyout or the drawer shows the palette windows, as @@ -1511,3 +1600,31 @@ fn wheel_without_a_move_uses_the_floating_viewports_pointer() { h.frame(vec![]); assert_ne!(h.app.run("text.fonts", json!({})).unwrap(), before); } + +#[test] +fn floating_collapse_arrow_in_a_native_window_moves_the_group_to_the_rail() { + let mut h = Harness::new(); + h.app.run("window.panel.float", json!({"panel": "layers", "group": true})).unwrap(); + h.app.run("window.panel.activate", json!({"panel": "layers"})).unwrap(); + h.frames(3); + let viewport = h.viewport("layers"); + h.host.borrow_mut().focus = viewport; + let arrow = h.host.borrow().windows[&viewport] + .texts + .iter() + .find(|(text, _)| text == "»") + .map(|(_, rect)| rect.center()) + .expect("the native floating header paints a collapse arrow"); + for pressed in [true, false] { + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([Event::PointerMoved(arrow), button(arrow, pressed)]); + h.frames(1); + } + h.frames(3); + let layout = layout_of(&h.app.ui); + assert!(floating_window(&layout, "layers").is_none()); + assert_eq!(group_of(&layout, &"layers".into()), ["properties", "layers", "libraries"]); + assert!(["properties", "layers", "libraries"].iter().all(|p| in_iconic_column(&layout, p))); + let frames = h.host.borrow().windows[&viewport].frames; + h.frames(3); + assert_eq!(h.host.borrow().windows[&viewport].frames, frames, "the emptied palette is no longer rendered"); +} diff --git a/crates/ui-egui/src/panel_docking/tests.rs b/crates/ui-egui/src/panel_docking/tests.rs index 60b59a2a8..1b8b1a168 100644 --- a/crates/ui-egui/src/panel_docking/tests.rs +++ b/crates/ui-egui/src/panel_docking/tests.rs @@ -274,7 +274,7 @@ fn switching_workspace_during_a_tab_drag_keeps_the_new_workspace_layout() { #[test] fn the_tools_panel_floats_by_its_title_bar_and_docks_on_the_left_edge() { let mut h = Harness::new(); - let zone = h.ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_id())).expect("the Tools panel's docking strip"); + let zone = docked_tools(&h); let title = zone.min + vec2(20.0, 12.0); h.drag(title, pos2(600.0, 400.0)); let [x, y] = h.app.ui.toolbar_pos.expect("the Tools panel floats"); @@ -284,6 +284,290 @@ fn the_tools_panel_floats_by_its_title_bar_and_docks_on_the_left_edge() { assert_eq!(h.app.ui.toolbar_pos, None, "dropped on the left edge, it docks"); } +/// The Tools panel also docks on the right, left of the panels: dropped on the window's right edge +/// it stands between the canvas and the panels, its flyouts open toward the canvas, the side is +/// kept with the preferences, and it drags back to the left edge. +#[test] +fn the_tools_panel_docks_on_the_right_left_of_the_panels() { + let mut h = Harness::new(); + let zone = docked_tools(&h); + h.drag(zone.min + vec2(20.0, 12.0), pos2(600.0, 400.0)); + let [x, y] = h.app.ui.toolbar_pos.expect("the Tools panel floats"); + h.drag(pos2(x + 20.0, y + 7.0), pos2(SCREEN.right() - 10.0, 500.0)); + assert_eq!((h.app.ui.toolbar_pos, h.app.ui.toolbar_right), (None, true), "dropped on the right edge, it docks on the right"); + settle(&mut h); + let bar = docked_tools(&h); + let canvas = h.app.canvas_rect.expect("the canvas"); + let layers = h.tab("layers").expect("the Layers panel, docked right"); + assert!(canvas.right() <= bar.left() + 1.0 && bar.right() <= layers.left() + 1.0, "canvas {canvas:?}, Tools {bar:?}, Layers {layers:?}"); + assert!(SCREEN.right() - bar.right() > 100.0, "the panels keep the window's right edge: {bar:?}"); + // Its flyouts and drawer open toward the canvas, not over the panels. + let room = crate::toolbar::room(&h.app, &h.ctx); + assert!(room.right() <= bar.right() + 0.5, "{room:?}"); + let left = crate::toolbar::beside(bar, 160.0, 8.0, room); + assert!(left + 160.0 <= bar.left() - 7.0, "left of the toolbar: {left}"); + let prefs: crate::state::UiState = serde_json::from_slice(&serde_json::to_vec(&h.app.ui).unwrap()).unwrap(); + assert!(prefs.toolbar_right, "kept with the preferences"); + // Dragged back to the window's left edge, it docks there again. + h.drag(bar.min + vec2(20.0, 12.0), pos2(600.0, 400.0)); + let [x, y] = h.app.ui.toolbar_pos.expect("floats again"); + h.drag(pos2(x + 20.0, y + 7.0), pos2(12.0, 500.0)); + assert_eq!((h.app.ui.toolbar_pos, h.app.ui.toolbar_right), (None, false)); + settle(&mut h); + assert!(docked_tools(&h).left() < 10.0, "at the window's left edge again"); + // Agents: window.panel.dock takes the side (left/right, or Start/End as panels do). + let r = h.app.run("window.panel.dock", json!({"panel": "tools", "side": "End"})).unwrap(); + assert_eq!((r["side"].as_str(), h.app.ui.toolbar_right), (Some("right"), true)); + h.app.run("window.panel.float", json!({"panel": "tools"})).unwrap(); + assert_eq!(h.app.run("window.panel.dock", json!({"panel": "tools"})).unwrap()["side"], "right", "back on the side it docked at last"); + assert!(h.app.run("window.panel.dock", json!({"panel": "tools", "side": "top"})).is_err()); + // A side that isn't text is an error too, not the last side: it leaves Tools where it is. + h.app.run("window.panel.float", json!({"panel": "tools"})).unwrap(); + for side in [json!(5), json!(true), json!(["left"])] { + assert!(h.app.run("window.panel.dock", json!({"panel": "tools", "side": side})).is_err(), "{side}"); + assert!(h.app.ui.toolbar_pos.is_some(), "still floating after side {side}"); + } + assert_eq!(h.app.run("window.panel.dock", json!({"panel": "tools", "side": null})).unwrap()["side"], "right", "null: the last side"); +} + +/// The docked Tools panel as last drawn. +fn docked_tools(h: &Harness) -> Rect { + h.ctx.data(|d| d.get_temp::(crate::toolbar::docked_rect_id())).expect("the docked Tools panel") +} + +fn settle(h: &mut Harness) { + for _ in 0..3 { + h.frame(vec![]); + } +} + +fn keep_tools_beside_canvas(h: &mut Harness, on: bool) { + assert_eq!(h.app.run("window.toolsBesideCanvas", json!({"on": on})).unwrap(), json!(on)); + settle(h); +} + +/// Where docked Tools sits with a left-side dock: `Some(true)` between the dock and the canvas, +/// `Some(false)` at the window's edge outside the dock. +fn tools_place(h: &Harness) -> Option { + let tools = docked_tools(h); + let layers = h.tab("layers")?; + let canvas = h.app.canvas_rect?; + if layers.right() <= tools.left() && tools.right() <= canvas.left() && canvas.left() - tools.right() < 40.0 { + Some(true) + } else if tools.left() < 10.0 && tools.right() <= layers.left() { + Some(false) + } else { + None + } +} + +/// Off by default: docked Tools stays at the window's edge, outside a left-side dock, and the +/// setting moves it between the dock and the canvas and back. +#[test] +fn docked_tools_keep_the_window_edge_unless_kept_beside_the_canvas() { + let mut h = Harness::new(); + assert!(!h.app.ui.tools_beside_canvas); + assert_eq!(crate::menus::checked(&h.app, "window.toolsBesideCanvas", &json!({})), Some(false)); + h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + settle(&mut h); + assert_eq!(tools_place(&h), Some(false), "Tools, then the left dock, then the canvas"); + assert_eq!(crate::toolbar::docked_width(&h.app.ui), 0.0, "the canvas reserves no room for Tools"); + keep_tools_beside_canvas(&mut h, true); + assert_eq!(crate::menus::checked(&h.app, "window.toolsBesideCanvas", &json!({})), Some(true)); + assert_eq!(tools_place(&h), Some(true), "the left dock, then Tools, then the canvas"); + keep_tools_beside_canvas(&mut h, false); + assert_eq!(tools_place(&h), Some(false), "back at the window's edge"); +} + +#[test] +fn keep_tools_beside_the_canvas_toggles_and_rejects_a_value_that_is_not_true_or_false() { + let mut h = Harness::new(); + assert_eq!(h.app.run("window.toolsBesideCanvas", json!({})).unwrap(), json!(true)); + assert_eq!(h.app.run("window.toolsBesideCanvas", json!({})).unwrap(), json!(false)); + assert!(h.app.run("window.toolsBesideCanvas", json!({"on": "yes"})).is_err()); + assert!(!h.app.ui.tools_beside_canvas, "a bad value changes nothing"); +} + +/// Off, only the window's edge docks a floating Tools panel; on, the canvas's edge does too, and +/// both dock it beside the canvas. +#[test] +fn tools_redock_at_the_targets_of_the_chosen_placement() { + for beside in [false, true] { + let mut h = Harness::new(); + keep_tools_beside_canvas(&mut h, beside); + h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + for at_window_edge in [false, true] { + h.app.run("window.panel.float", json!({"panel": "tools", "x": 600, "y": 300})).unwrap(); + settle(&mut h); + let canvas = h.app.canvas_rect.unwrap(); + let canvas_edge = h.ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_id())); + assert_eq!(canvas_edge.is_some(), beside, "a canvas-edge target only while kept beside the canvas"); + let canvas_edge = canvas_edge.map_or(pos2(canvas.left() + 12.0, canvas.center().y), |r| r.center()); + let target = if at_window_edge { pos2(12.0, canvas.center().y) } else { canvas_edge }; + h.drag(pos2(620.0, 307.0), target); + if !beside && !at_window_edge { + assert!(h.app.ui.toolbar_pos.is_some(), "the canvas's edge does not dock Tools while the setting is off"); + continue; + } + assert_eq!(h.app.ui.toolbar_pos, None, "beside: {beside}, at the window edge: {at_window_edge}"); + settle(&mut h); + assert_eq!(tools_place(&h), Some(beside), "beside: {beside}, at the window edge: {at_window_edge}"); + } + } +} + +/// Turning the setting off while Tools floats forgets the canvas-edge target, and turning it on +/// leaves a floating Tools panel floating. +#[test] +fn toggling_while_tools_floats_keeps_it_floating_and_drops_the_canvas_target() { + let mut h = Harness::new(); + h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + keep_tools_beside_canvas(&mut h, true); + h.app.run("window.panel.float", json!({"panel": "tools", "x": 600, "y": 300})).unwrap(); + settle(&mut h); + let zone = h.ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_id())).expect("the canvas-edge target"); + keep_tools_beside_canvas(&mut h, false); + assert!(h.app.ui.toolbar_pos.is_some(), "turning it off leaves Tools floating"); + assert!(h.ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_id())).is_none(), "the canvas-edge target is gone"); + h.drag(pos2(620.0, 307.0), zone.center()); + assert!(h.app.ui.toolbar_pos.is_some(), "the old canvas-edge strip docks nothing"); + let [x, y] = h.app.ui.toolbar_pos.unwrap(); + keep_tools_beside_canvas(&mut h, true); + assert_eq!(h.app.ui.toolbar_pos, Some([x, y]), "turning it on leaves Tools floating where it was"); + keep_tools_beside_canvas(&mut h, false); + h.drag(pos2(x + 20.0, y + 7.0), pos2(12.0, zone.center().y)); + assert_eq!(h.app.ui.toolbar_pos, None); + settle(&mut h); + assert_eq!(tools_place(&h), Some(false)); +} + +/// The setting is kept with a workspace; state and workspaces saved without it keep Tools at the +/// window's edge. +#[test] +fn keep_tools_beside_the_canvas_is_saved_and_off_in_state_saved_without_it() { + let mut saved = serde_json::to_value(crate::state::UiState { tools_beside_canvas: true, ..Default::default() }).unwrap(); + assert_eq!(saved["tools_beside_canvas"], json!(true)); + let reloaded: crate::state::UiState = serde_json::from_value(saved.clone()).unwrap(); + assert!(reloaded.tools_beside_canvas); + saved.as_object_mut().unwrap().remove("tools_beside_canvas"); + let old: crate::state::UiState = serde_json::from_value(saved).unwrap(); + assert!(!old.tools_beside_canvas && old.toolbar_pos.is_none(), "older state docks Tools at the window's edge"); + + let mut h = Harness::new(); + h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + keep_tools_beside_canvas(&mut h, true); + let mut workspace = serde_json::to_value(crate::workspaces::capture(&h.app.ui, "Mine")).unwrap(); + assert_eq!(workspace["toolsBesideCanvas"], json!(true)); + workspace.as_object_mut().unwrap().remove("toolsBesideCanvas"); + let old: crate::workspaces::Workspace = serde_json::from_value(workspace).unwrap(); + assert!(!old.tools_beside_canvas, "a workspace saved without it docks Tools at the window's edge"); + + h.app.run("window.workspace.new", json!({"name": "Mine"})).unwrap(); + h.app.run("window.workspace", json!({"name": "Essentials"})).unwrap(); + settle(&mut h); + assert!(!h.app.ui.tools_beside_canvas, "built-in workspaces keep Tools at the window's edge"); + h.app.run("window.workspace", json!({"name": "Mine"})).unwrap(); + settle(&mut h); + assert!(h.app.ui.tools_beside_canvas); + assert_eq!(tools_place(&h), Some(true), "the saved workspace brings Tools back beside the canvas"); +} + +#[test] +fn tools_follow_left_panels_when_resized_hidden_and_restored() { + for (beside, double) in [(false, false), (false, true), (true, false), (true, true)] { + let mut h = Harness::new(); + keep_tools_beside_canvas(&mut h, beside); + h.app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + gesture(&mut h.app, Action::ResizeColumn { panel: "layers".into(), width: 430.0 }).unwrap(); + h.app.ui.toolbar_double = double; + let width = if double { 80.0 } else { 48.0 }; + for hidden in [false, true, false] { + if hidden == h.app.ui.dock { + h.app.run("window.panels", json!({})).unwrap(); + } + settle(&mut h); + let tools = docked_tools(&h); + assert!((tools.width() - (width + 6.0)).abs() < 1.0, "{} columns: {tools:?}", if double { 2 } else { 1 }); + if hidden { + assert!(h.tab("layers").is_none()); + assert!(tools.left() < 10.0); + } else { + assert_eq!(tools_place(&h), Some(beside), "beside: {beside}, double: {double}"); + assert!(h.tab("layers").unwrap().width() > 0.0); + } + let canvas = h.app.canvas_rect.unwrap(); + assert!(canvas.left() - tools.left() >= width, "Tools has its own room: {tools:?}, {canvas:?}"); + assert!(canvas.width() > 200.0); + } + } +} + +/// A layout with panels above or below the canvas: the full-height strip along the window's left +/// edge docks Tools in both placements; off, Tools runs the window's height beside those panels, +/// and on, it stays in the canvas's row. +#[test] +fn tools_redock_at_window_edge_above_and_below_canvas() { + for (beside, above) in [(false, true), (false, false), (true, true), (true, false)] { + let mut h = Harness::new(); + keep_tools_beside_canvas(&mut h, beside); + let tabs = |panel: &str| Box::new(Node::Tabs { panels: vec![panel.to_string()], active: 0 }); + h.app.ui.docking = Some(Layout { + root: Some(Node::Split { + axis: craft_ui::layout::SplitAxis::Vertical, + size: craft_ui::layout::SplitSize::FixedFirst(180.0), + first: tabs("layers"), + second: Box::new(Node::Split { + axis: craft_ui::layout::SplitAxis::Vertical, + size: craft_ui::layout::SplitSize::FixedSecond(180.0), + first: tabs(CANVAS), + second: tabs("properties"), + }), + }), + floating: vec![], + }); + assert!(valid(&h.layout())); + h.app.run("window.panel.float", json!({"panel": "tools", "x": 600, "y": 300})).unwrap(); + for _ in 0..3 { + h.frame(vec![]); + } + let panel = h.tab(if above { "layers" } else { "properties" }).unwrap(); + let canvas = h.app.canvas_rect.unwrap(); + assert!(panel.center().y < canvas.top() || panel.center().y > canvas.bottom()); + h.drag(pos2(620.0, 307.0), pos2(12.0, panel.center().y)); + assert_eq!(h.app.ui.toolbar_pos, None, "window-edge drop, beside: {beside}, above the canvas: {above}"); + settle(&mut h); + let tools = docked_tools(&h); + let panel = h.tab(if above { "layers" } else { "properties" }).unwrap(); + assert!(tools.left() < 10.0 && tools.right() <= panel.left() + 60.0, "{tools:?}"); + match (beside, above) { + (false, true) => assert!(tools.top() <= panel.top(), "Tools runs past the panel above: {tools:?}, {panel:?}"), + (false, false) => assert!(tools.bottom() >= panel.bottom(), "Tools runs past the panel below: {tools:?}, {panel:?}"), + (true, true) => assert!(tools.top() >= panel.bottom(), "Tools stays below the panel above: {tools:?}, {panel:?}"), + (true, false) => assert!(tools.bottom() <= panel.top(), "Tools stays above the panel below: {tools:?}, {panel:?}"), + } + } +} + +#[test] +#[ignore = "manual visual check; set LEFT_TOOLS_SCREENSHOTS to an existing harness directory"] +fn capture_tools_beside_left_panels() { + let dir = std::path::PathBuf::from(std::env::var_os("LEFT_TOOLS_SCREENSHOTS").unwrap()); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("window.toolsBesideCanvas", json!({"on": true})).unwrap(); + app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + let mut h = egui_kittest::Harness::builder() + .with_size(vec2(1200.0, 800.0)) + .with_accessibility_check(false) + .wgpu() + .build_ui_state(|ui, app: &mut VectorcraftApp| app.ui(ui), app); + h.run_steps(6); + h.render().unwrap().save(dir.join("tools-inside-left-dock.png")).unwrap(); + h.state_mut().ui.toolbar_double = true; + h.run_steps(3); + h.render().unwrap().save(dir.join("tools-inside-left-dock-double.png")).unwrap(); +} + #[test] fn a_library_panel_opens_renames_and_closes() { let mut h = Harness::new(); @@ -759,3 +1043,118 @@ fn other_function_keys_and_keyboard_guards_keep_their_routes() { assert_eq!(saved.session.doc().unwrap().doc.node_count(), 2, "F12 reloads the saved document"); std::fs::remove_dir_all(dir).unwrap(); } + +fn collapse_to_icons_button(h: &Harness, panel: &str) -> Option { + let panel = panel.to_string(); + h.ctx.read_response(area_id().with(("header", &panel)).with(("collapse-to-icons", &panel))).map(|r| r.rect) +} + +#[test] +fn floating_collapse_arrow_moves_the_whole_group_into_the_icon_rail() { + let mut h = Harness::new(); + assert!(collapse_to_icons_button(&h, "properties").is_none(), "docked headers have no extra button"); + h.app.run("window.panel.float", json!({"panel": "layers", "group": true})).unwrap(); + h.app.run("window.panel.activate", json!({"panel": "layers"})).unwrap(); + for _ in 0..3 { + h.frame(vec![]); + } + let before = h.layout(); + let members = group_of(&before, &"layers".into()); + let columns = end_columns(&before).len(); + let arrow = collapse_to_icons_button(&h, "layers").expect("floating header offers collapse to icons"); + assert!(arrow.width() <= 24.0 && arrow.height() <= 24.0, "a compact header control: {arrow:?}"); + h.click(arrow.center()); + let layout = h.layout(); + assert!(floating_window(&layout, "layers").is_none(), "the old floating window is removed"); + assert_eq!(group_of(&layout, &"layers".into()), members, "all tabs move together"); + assert!(members.iter().all(|p| in_iconic_column(&layout, p))); + assert_eq!(end_columns(&layout).len(), columns, "reuse the existing icon rail"); + assert!(h.app.ui.open_panel.is_none(), "the group stays collapsed until its icon is clicked"); + let icon = h.hit(&Hit::Icon("layers".into())).expect("Layers now has a rail icon"); + h.click(icon.center()); + assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers")); + assert!(h.tab("layers").is_some(), "the collapsed group can reopen as a flyout"); + assert!(collapse_to_icons_button(&h, "layers").is_some(), "the reopened flyout also offers collapse to icons"); +} + +#[test] +fn floating_collapse_arrow_leaves_other_floating_groups_alone() { + let mut h = Harness::new(); + h.app.run("window.panel.float", json!({"panel": "layers"})).unwrap(); + h.app.run("window.panel.float", json!({"panel": "color", "below": "layers"})).unwrap(); + for _ in 0..3 { + h.frame(vec![]); + } + let arrow = collapse_to_icons_button(&h, "layers").expect("floating group has its own arrow"); + h.click(arrow.center()); + let layout = h.layout(); + assert!(in_iconic_column(&layout, "layers")); + assert!(floating_window(&layout, "color").is_some(), "another group in the same window stays floating"); + assert!(!in_iconic_column(&layout, "color")); + assert_eq!(main_panels(&layout), ["properties", "libraries"]); +} + +#[test] +fn floating_collapse_arrow_creates_a_rail_when_none_exists_and_keeps_the_menu() { + let mut h = Harness::new(); + h.app.run("window.panel.float", json!({"panel": "layers"})).unwrap(); + let mut layout = h.layout(); + if let Some(Node::Split { second, .. }) = layout.root.as_mut() + && let Node::Dock { columns, .. } = second.as_mut() + { + columns.retain(|column| !column.iconic); + } + assert!(icon_column(&end_columns(&layout)).is_none()); + h.app.ui.docking = Some(layout); + for _ in 0..3 { + h.frame(vec![]); + } + let panel = "layers".to_string(); + let menu = + h.ctx.read_response(area_id().with(("header", &panel)).with(("panel-menu", &panel))).expect("the existing panel menu remains available").rect; + let arrow = collapse_to_icons_button(&h, "layers").expect("floating group offers collapse"); + assert!(!menu.intersects(arrow), "menu and arrow have separate hit targets"); + h.click(menu.center()); + assert!(egui::Popup::is_any_open(&h.ctx), "the existing menu still opens"); + h.press(egui::Key::Escape, egui::Modifiers::NONE); + h.click(arrow.center()); + let layout = h.layout(); + assert!(in_iconic_column(&layout, "layers")); + assert!(floating_window(&layout, "layers").is_none()); + assert!(h.app.ui.open_panel.is_none(), "collapse does not immediately open a flyout"); + assert_eq!(main_panels(&layout), ["properties", "libraries"]); +} + +#[test] +fn rail_flyout_collapse_arrow_folds_the_panel_back_without_moving_it() { + let mut h = Harness::new(); + let icon = h.hit(&Hit::Icon("swatches".into())).unwrap(); + h.click(icon.center()); + let before = h.layout(); + let arrow = collapse_to_icons_button(&h, "swatches").expect("opened rail panels offer collapse to icons"); + h.click(arrow.center()); + assert!(h.app.ui.open_panel.is_none()); + assert!(craft_ui::docking::flyout::(&h.ctx, area_id()).is_none()); + assert_eq!(h.layout(), before, "folding a flyout leaves the dock layout intact"); + let icon = h.hit(&Hit::Icon("swatches".into())).expect("the rail icon stays available"); + h.click(icon.center()); + assert_eq!(h.app.ui.open_panel.as_deref(), Some("swatches")); + assert!(h.tab("swatches").is_some()); +} + +#[test] +fn rail_flyout_collapse_arrow_closes_the_opening_icon_after_switching_tabs() { + let mut h = Harness::new(); + h.app.ui.docking = Some(h.layout()); + let icon = h.hit(&Hit::Icon("swatches".into())).unwrap(); + h.click(icon.center()); + let brushes = h.tab("brushes").unwrap(); + h.click(brushes.center()); + assert_eq!(h.app.ui.open_panel.as_deref(), Some("swatches")); + let before = h.layout(); + let arrow = collapse_to_icons_button(&h, "brushes").expect("the active tab has a collapse arrow"); + h.click(arrow.center()); + assert!(h.app.ui.open_panel.is_none()); + assert!(craft_ui::docking::flyout::(&h.ctx, area_id()).is_none()); + assert_eq!(h.layout(), before, "all tabs and their active state stay in the rail"); +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 84eaf8791..2282633eb 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -15,6 +15,7 @@ pub mod character; pub mod color; pub mod color_guide; pub mod color_themes; +pub mod color_wheel; pub mod css_properties; pub mod doc_info; pub mod flattener_preview; @@ -177,6 +178,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { svg_interactivity::ID => svg_interactivity::show(app, ui), variables::ID => variables::show(app, ui), "colorThemes" => color_themes::show(app, ui), + color_wheel::ID => color_wheel::show(app, ui), links::ID => links::show(app, ui), asset_export::ID => asset_export::show(app, ui), css_properties::ID => css_properties::show(app, ui), @@ -655,6 +657,8 @@ mod tests_blend; #[cfg(test)] mod tests_brush_libraries; #[cfg(test)] +mod tests_color_wheel; +#[cfg(test)] mod tests_constrain; #[cfg(test)] mod tests_css_properties; diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 64c8e6fbc..93d781e5a 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -2680,6 +2680,191 @@ mod tests { assert_eq!(*asked.borrow(), [files::MAX_SET_FILE + 1; 2], "each read stops one byte past the limit"); } + /// An action set as another app saves it as text, written from scratch: the set `name` (none + /// when empty) and actions of steps, each a label and whether it is on, with no recorded values. + fn aia_text(name: &str, actions: &[(&str, &[(&str, bool)])]) -> String { + let hex = |t: &str| format!("[ {} {} ]", t.len(), t.bytes().map(|b| format!("{b:02x}")).collect::()); + let mut out = String::from("/version 3\n"); + if !name.is_empty() { + out += &format!("/name {}\n", hex(name)); + } + out += &format!("/isOpen 1\n/actionCount {}\n", actions.len()); + for (i, (action, steps)) in actions.iter().enumerate() { + out += &format!("/action-{} {{\n\t/name {}\n\t/eventCount {}\n", i + 1, hex(action), steps.len()); + for (j, (label, on)) in steps.iter().enumerate() { + out += &format!( + "\t/event-{} {{\n\t\t/internalName (sample_step_{j})\n\t\t/localizedName {}\n\t\t/isOn {}\n\t\t/parameterCount 0\n\t}}\n", + j + 1, + hex(label), + u8::from(*on) + ); + } + out += "}\n"; + } + out + } + + /// Load Actions… reads an action set another app saved as text: its actions come in with the + /// steps with no recorded values whose label is a VectorCraft command's that takes no values and + /// has no dialog, a step that is off stays excluded from playback, the other steps are left out + /// and listed, and the status bar says how many. An action plays the steps that are on. + #[test] + fn load_actions_reads_another_apps_action_set() { + let mut app = app_with_sets(); + let text = aia_text( + "Tidy Up", + &[("Group Them", &[("group", true), ("Move", true), ("Ungroup", false)]), ("Nothing", &[]), ("", &[("Ungroup", true)])], + ); + let r = app.run(LOAD_SET, json!({ "data": text })).unwrap(); + let skipped = json!([{"action": "Group Them", "step": "Move"}]); + assert_eq!(r, json!({"set": 3, "name": "Tidy Up", "actions": 3, "skipped": skipped, "leftOut": 1})); + let set = &app.ui.action_sets[3]; + assert_eq!(set.actions[0].name, "Group Them"); + assert_eq!(set.actions[0].steps, [("object.group".to_string(), json!({})), ("object.ungroup".to_string(), json!({}))]); + assert_eq!(set.actions[0].excluded_steps, [1].into()); + assert!(set.actions[1].steps.is_empty()); + assert_eq!(set.actions[2].name, "Action 3", "an action with no name"); + assert_eq!(app.ui.status, "Loaded “Tidy Up”: 1 step was left out"); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + for x in [0, 50] { + app.run("shape.rectangle", json!({"x": x, "y": 0, "width": 20, "height": 20})).unwrap(); + } + app.run("select.all", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().selection.objects.len(), 2); + play(&mut app, 3, 0).unwrap(); + assert_eq!(app.session.active().unwrap().selection.objects.len(), 1, "the action grouped the rectangles and did not ungroup them"); + } + + /// A step recorded with values is left out even when a command has its label: a step that set an + /// opacity does not select by opacity. A step whose values name a menu command, in two or three + /// values, is left out too. The status bar counts the steps left out. + #[test] + fn recorded_values_keep_a_step_out() { + let hex = |t: &str| format!("[ {} {} ]", t.len(), t.bytes().map(|b| format!("{b:02x}")).collect::()); + let text = |t: &str| format!("/type (ustring) /value {}", hex(t)); + let number = || "/type (real) /value 60.0".to_string(); + let menu = |shown: &str| vec![text("some_command"), text(shown), number()]; + let steps = [ + ("Opacity", vec![number()]), + ("Stroke Weight", vec![number()]), + ("A Menu Step", menu("Group")), + ("A Menu Step", vec![text("some_command"), text("Ungroup")]), + ("Rename", vec![text("OldLayer"), text("Group")]), + ("Group", vec![number(), text("Group"), number()]), + ("Group", vec![]), + ]; + let events: String = steps + .iter() + .enumerate() + .map(|(i, (label, values))| { + let params: String = values.iter().enumerate().map(|(n, v)| format!("/parameter-{} {{ {v} }} ", n + 1)).collect(); + format!("/event-{} {{ /localizedName {} /parameterCount {} {params} }} ", i + 1, hex(label), values.len()) + }) + .collect(); + let set = format!("/version 3 /name {} /action-1 {{ /name {} {events} }}", hex("Values"), hex("A")); + let mut app = app_with_sets(); + let r = app.run(LOAD_SET, json!({ "data": set })).unwrap(); + assert_eq!(app.ui.action_sets[3].actions[0].steps, [("object.group".to_string(), json!({}))]); + assert_eq!(r["leftOut"], 6); + let left_out: Vec<&str> = r["skipped"].as_array().unwrap().iter().map(|s| s["step"].as_str().unwrap()).collect(); + assert_eq!(left_out, ["Opacity", "Stroke Weight", "A Menu Step", "A Menu Step", "Rename", "Group"]); + assert_eq!(app.ui.status, "Loaded “Values”: 6 steps were left out"); + } + + /// A step with no label, or a blank one, is left out and listed by its internal name, even when + /// that name is a command's label. + #[test] + fn a_step_with_no_label_is_left_out() { + let set = "/version 3 /name [ 4 4e6f6e65 ] /action-1 { /name [ 1 41 ] \ + /event-1 { /internalName (group) /parameterCount 0 } \ + /event-2 { /internalName (Ungroup) /localizedName [ 1 20 ] /parameterCount 0 } \ + /event-3 { /internalName (x) /localizedName [ 5 47726f7570 ] /parameterCount 0 } }"; + let mut app = app_with_sets(); + let r = app.run(LOAD_SET, json!({ "data": set })).unwrap(); + assert_eq!(app.ui.action_sets[3].actions[0].steps, [("object.group".to_string(), json!({}))]); + assert_eq!(r["skipped"], json!([{"action": "A", "step": "group"}, {"action": "A", "step": "Ungroup"}])); + assert_eq!(r["leftOut"], 2); + } + /// Load Actions… picks action set files of both kinds, and a set another app saved without a + /// name takes the file's name, or `Actions` when there is no file; one named like a set in the + /// panel gets the next free number. A file that isn't UTF-8 still reads. A set past the reader's + /// caps, one whose text ends inside a block, one whose counts differ from what follows and one + /// with a damaged name are refused, and text that isn't an action set is not read. + #[test] + fn load_actions_names_another_apps_set_after_its_file() { + let mut app = app_with_sets(); + let files = with_files(&mut app, "/sets/Mine.aia"); + let mut bytes = aia_text("", &[("One", &[("Ungroup", true)])]).into_bytes(); + bytes.extend(b"/note (\xFF)\n"); + files.borrow_mut().insert("/sets/Mine.aia".into(), bytes); + let r = app.run(LOAD_SET, json!({})).unwrap(); + assert_eq!((r["name"].clone(), r["leftOut"].clone()), (json!("Mine 2"), json!(0))); + assert_eq!(app.ui.action_sets[3].actions[0].steps, [("object.ungroup".to_string(), json!({}))]); + let actions = vec![("A", [("Group", true)].as_slice()); 1001]; + let r = app.run(LOAD_SET, json!({ "data": aia_text("Big", &actions) })); + assert_eq!(r.unwrap_err(), "the action set file can't be read in full"); + let cut = aia_text("Cut", &[("A", &[("Group", true)])]); + let cut = cut.trim_end().strip_suffix('}').unwrap().to_string(); + assert_eq!(app.run(LOAD_SET, json!({ "data": cut })).unwrap_err(), "the action set file can't be read in full"); + let counts = aia_text("Counts", &[("A", &[]), ("B", &[])]).replace("/actionCount 2", "/actionCount 1"); + assert_eq!(app.run(LOAD_SET, json!({ "data": counts })).unwrap_err(), "the action set file can't be read in full"); + for damaged in ["/version 3 /action-1 { /name [ 3 41 ] }", "/version 3 /name [ 3 41 ]"] { + assert_eq!(app.run(LOAD_SET, json!({ "data": damaged })).unwrap_err(), "the action set file can't be read in full"); + } + for not_a_set in ["/version three", "/versionX 3"] { + assert_eq!(app.run(LOAD_SET, json!({ "data": not_a_set })).unwrap_err(), "not an action set file"); + } + assert_eq!(app.ui.action_sets.len(), 4, "no set added"); + let r = app.run(LOAD_SET, json!({ "data": aia_text("", &[]) })).unwrap(); + assert_eq!(r["name"], "Actions"); + } + + /// A step stands for a command only when no other menu command has its label, and the command is + /// recordable, runs with no values, has no dialog and sits directly in a top menu. The item of a + /// submenu stands for nothing: its label alone does not say which command it is. + #[test] + fn steps_stand_for_commands_that_run_with_no_values() { + assert_eq!(files::step_command("Group"), Some("object.group")); + assert_eq!(files::step_command(" GROUP... "), Some("object.group")); + for label in ["Move", "Move…", "", "No Such Command"] { + assert_eq!(files::step_command(label), None, "{label}"); + } + let submenu_items = ["Opacity", "Stroke Weight", "Fill Color", "Blending Mode", "Grid", "Radial", "Create", "Delete All"]; + let matched: Vec<&str> = submenu_items.into_iter().filter(|label| files::step_command(label).is_some()).collect(); + assert!(matched.is_empty(), "{matched:?}"); + // The menu bar's Crop Image item runs another command, which opens the crop tool. + let not_on_the_menu_bar = ["Crop Image", "Expand Gradient Mesh", "Duplicate"]; + let matched: Vec<&str> = not_on_the_menu_bar.into_iter().filter(|label| files::step_command(label).is_some()).collect(); + assert!(matched.is_empty(), "{matched:?}"); + } + + /// A step does not stand for a command that closes, reverts or writes documents, for Paste, for + /// Undo or Redo, or for an add-on's command: such a step is left out, and nothing is closed on + /// playback. + #[test] + fn steps_do_not_stand_for_file_commands_paste_undo_or_add_ons() { + let labels = ["Close", "Close All", "Revert", "Export for Screens", "Paste", "Undo", "Redo", "Close Path", "Save a Timestamped Copy"]; + let matched: Vec<&str> = labels.into_iter().filter(|label| files::step_command(label).is_some()).collect(); + assert!(matched.is_empty(), "{matched:?}"); + let mut app = app_with_sets(); + let r = app.run(LOAD_SET, json!({ "data": aia_text("Closes", &[("A", &[("Close", true), ("Group", true)])]) })).unwrap(); + assert_eq!(r["leftOut"], 1); + assert_eq!(app.ui.action_sets[3].actions[0].steps, [("object.group".to_string(), json!({}))]); + } + + /// A set loaded from another app's file saves to VectorCraft's own file with its excluded steps, + /// and loads back the same. + #[test] + fn another_apps_set_saves_and_loads_back_with_its_excluded_steps() { + let mut app = app_with_sets(); + let text = aia_text("Tidy", &[("A", &[("Group", true), ("Ungroup", false)])]); + app.run(LOAD_SET, json!({ "data": text })).unwrap(); + let saved = files::write_set(&app.ui.action_sets[3]); + let r = app.run(LOAD_SET, json!({ "data": saved })).unwrap(); + assert_eq!(r, json!({"set": 4, "name": "Tidy 2", "actions": 1})); + assert_eq!(app.ui.action_sets[4].actions[0].excluded_steps, [1].into()); + } + /// Save Actions… does not write a set that Load Actions… would refuse: more than 1,000 actions. #[test] fn save_actions_does_not_write_a_set_load_actions_refuses() { diff --git a/crates/ui-egui/src/panels/actions/files.rs b/crates/ui-egui/src/panels/actions/files.rs index 4e5092cef..f7df78ee9 100644 --- a/crates/ui-egui/src/panels/actions/files.rs +++ b/crates/ui-egui/src/panels/actions/files.rs @@ -9,6 +9,8 @@ //! playback. Sets with exclusions use version 2 so older builds refuse them rather than replay //! excluded steps, and sets with stops use version 3 so older builds refuse them rather than fail //! at a stop. Other sets use version 1, and earlier version 1 and 2 files still work. +//! Load Actions… also reads the action sets another app saves as text (`.aia`, through +//! `actionSet.read`). use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -34,6 +36,9 @@ const MAX_SET_ACTIONS: usize = 1000; /// The most steps [`read_set`] reads in one action. const MAX_ACTION_STEPS: usize = 10_000; +/// The most left-out steps that Load Actions… lists for an action set another app saved. +const MAX_SKIPPED: usize = 1000; + /// An action set file: a header, the set's name and its actions. #[derive(Serialize, Deserialize)] struct SetFile { @@ -201,12 +206,158 @@ pub(crate) fn can_load(app: &VectorcraftApp) -> bool { crate::picks::can(app, &crate::picks::PickRequest::Open(crate::FilePick::default())) } +/// The bytes start with `/version`, as the action sets another app saves as text (`.aia`) do. +fn is_aia(bytes: &[u8]) -> bool { + bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes).trim_ascii_start().starts_with(b"/version") +} + +/// `label` in any case, without a final `…` or `...`. +fn bare_label(label: &str) -> String { + label.trim().trim_end_matches("...").trim_end_matches('…').trim().to_lowercase() +} + +/// Whether a step of another app's action set may stand for command `id`. `command.batch` runs a +/// step without the app's handling of it, so a file or document command would close, revert or write +/// documents without asking, and a Paste would skip the view's center and the swatch conflict +/// question. Undo and Redo would act on the action's own undo step, and an add-on command with a +/// label a file also uses is not what the file recorded. +fn may_stand_for(id: &str) -> bool { + !(id.starts_with("file.") + || id.starts_with("document.") + || id.starts_with("addon.") + || id.starts_with("edit.paste") + || matches!(id, "edit.undo" | "edit.redo")) +} + +/// The name of a step of `actionSet.read`'s answer, and whether the step can stand for a command. A +/// step goes by its label. Only a step with a label and no recorded values can stand for a command; +/// a step recorded with values, a recorded menu item included, is left out. A step with no label is +/// listed by its name. +fn step_name(step: &Value) -> (String, bool) { + let label = step.get("label").and_then(Value::as_str); + let params = step.get("params").and_then(Value::as_u64).unwrap_or_default(); + match label { + Some(label) => (label.to_string(), params == 0), + None => (step.get("name").and_then(Value::as_str).unwrap_or_default().to_string(), false), + } +} + +/// The commands a step of another app's action set can stand for, by label, in English and in the +/// interface language ([`bare_label`]). A label stands for a command when no other menu command has +/// it, and the command is recordable, is run by a menu bar item with no params +/// ([`runs_with_no_values`](crate::shortcut_editor::runs_with_no_values)), has no `…` in its label, +/// sits directly in a top menu and is one [`may_stand_for`] allows. The +/// label of a submenu's item does not say which command it is: Select › Same › Opacity is "Opacity". +fn step_commands() -> std::collections::HashMap> { + let mut by_label = std::collections::HashMap::new(); + for c in vectorcraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty()) { + let can_stand = + may_stand_for(c.id) && c.journal && c.menu.len() == 1 && !c.label.contains('…') && crate::shortcut_editor::runs_with_no_values(c); + let mut labels = vec![bare_label(c.label), bare_label(crate::i18n::t_id(c.id, c.label))]; + labels.dedup(); + for label in labels.into_iter().filter(|l| !l.is_empty()) { + by_label + .entry(label) + .and_modify(|id: &mut Option<&'static str>| *id = id.filter(|id| can_stand && *id == c.id)) + .or_insert(can_stand.then_some(c.id)); + } + } + by_label +} + +/// The command a step of another app's action set stands for ([`step_commands`]). +#[cfg(test)] +pub(super) fn step_command(label: &str) -> Option<&'static str> { + step_commands().get(&bare_label(label)).copied().flatten() +} + +/// What Load Actions… made of an action set another app saved as text. +struct AiaLoad { + set: ActionSet, + /// The first [`MAX_SKIPPED`] steps left out: `[{action, step}]`. + skipped: Vec, + /// How many steps were left out. + left_out: usize, +} + +/// The set of `read`, `actionSet.read`'s answer, named `name` when the file gives none. A step comes +/// in as the command it stands for ([`step_name`], [`step_commands`]), with no params, and excluded +/// from playback when it is off in the file. An action with no name is called `Action N`. +fn aia_set(read: &Value, name: &str) -> AiaLoad { + let (mut skipped, mut left_out) = (vec![], 0usize); + let commands = step_commands(); + let text = |v: &Value, key: &str| v.get(key).and_then(Value::as_str).unwrap_or_default().to_string(); + let actions = read + .get("actions") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .enumerate() + .map(|(i, a)| { + let action = Some(text(a, "name")).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| format!("Action {}", i + 1)); + let (mut steps, mut excluded_steps) = (vec![], std::collections::BTreeSet::new()); + for s in a.get("steps").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() { + let (name, can_stand) = step_name(s); + let Some(id) = commands.get(&bare_label(&name)).copied().flatten().filter(|_| can_stand) else { + left_out += 1; + if skipped.len() < MAX_SKIPPED { + skipped.push(json!({ "action": action, "step": name })); + } + continue; + }; + if s.get("on").and_then(Value::as_bool) == Some(false) { + excluded_steps.insert(steps.len()); + } + steps.push((id.to_string(), json!({}))); + } + Action { excluded_steps, ..Action::new(action, steps) } + }) + .collect(); + let set = text(read, "set"); + AiaLoad { set: ActionSet::new(if set.trim().is_empty() { name.to_string() } else { set }, actions), skipped, left_out } +} + +/// [`load_set`] of an action set another app saved as text, which `actionSet.read` reads with +/// `params`. +fn load_aia(app: &mut VectorcraftApp, params: Value) -> Result { + // Text that starts `/version` and no number is not an action set either. + let read = app.run("actionSet.read", params).map_err(|_| "not an action set file")?; + // A set read only in part would play actions that differ from the file's. + if read.get("truncated").and_then(Value::as_bool).unwrap_or(false) { + return Err("the action set file can't be read in full".into()); + } + let AiaLoad { mut set, skipped, left_out } = aia_set(&read, "Actions"); + set.name = free_set_name(&app.ui.action_sets, &set.name); + let (name, actions) = (set.name.clone(), set.actions.len()); + app.ui.action_sets.push(set); + if left_out > 0 { + app.status(crate::i18n::msg_n( + left_out as u64, + "Loaded “{name}”: {n} step was left out", + "Loaded “{name}”: {n} steps were left out", + &[("name", &name)], + )); + } + Ok(json!({ + "set": app.ui.action_sets.len() - 1, "name": name, "actions": actions, + "skipped": skipped, "leftOut": left_out, + })) +} + /// `ui.actionSet.load {path? | data?}`: reads the action set file at `path` (else a picked one), or /// the file's text `data`, and adds its set after the others, with its actions. A set with the /// same name as one in the panel gets the next free number (`Print 2`). Loaded actions keep their /// function keys. The current set and the other sets stay as they are → /// `{set: index, name, actions: n}`. Where the host can read part of a file (the desktop), a file /// larger than 8 MiB is refused after its first 8 MiB and one byte are read. +/// +/// An action set another app saved as text (`.aia`, read with `actionSet.read`) comes in with its +/// actions and the steps that stand for a VectorCraft command ([`step_name`], [`step_commands`]), +/// with no params; a step that is off in the file is excluded from playback. A set with no name is +/// called after the file, else `Actions`. The other steps are left out and listed, and the status +/// bar says how many → `{set, name, actions, skipped: [{action, step}] (at most 1,000), leftOut: n}`. +/// Such a file that `actionSet.read` marks `truncated` is refused, and no set is added. pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result { let (text, stem) = match p.get("data") { Some(Value::String(data)) => { @@ -214,6 +365,9 @@ pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result { if data.len() > MAX_SET_FILE { return Err("the action set file is larger than 8 MiB".into()); } + if is_aia(data.as_bytes()) { + return load_aia(app, json!({ "data": data })); + } (data.clone(), String::new()) } Some(_) => return Err("not an action set file".into()), @@ -221,7 +375,8 @@ pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result { let path = match p.get("path").and_then(Value::as_str) { Some(path) => path.to_string(), None => { - let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Action sets", &[ACTIONS_EXT])], ..Default::default() }; + let exts = [&[ACTIONS_EXT], vectorcraft_engine::cmd::actionset::EXTS].concat(); + let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Action sets", &exts)], ..Default::default() }; crate::picks::open(app, &pick).ok_or("cancelled")? } }; @@ -234,6 +389,9 @@ pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result { if bytes.len() > MAX_SET_FILE { return Err("the action set file is larger than 8 MiB".into()); } + if is_aia(&bytes) { + return load_aia(app, json!({ "name": path, "dataBase64": vectorcraft_format::base64_encode(&bytes) })); + } let stem = std::path::Path::new(&path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); (String::from_utf8(bytes).map_err(|_| "not an action set file")?, stem) } diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 1cb2ede41..ca1f3c28f 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -251,6 +251,21 @@ pub(crate) fn shown_effect_label(id: &str) -> String { } } +/// An effect row's name: [`shown_effect_label`], followed by "(Safe Mode)" for a plug-in effect +/// Safe Mode skips (the object keeps it and draws without it). +pub fn effect_row_label(id: &str) -> String { + let label = shown_effect_label(id); + if vectorcraft_plugins::effect::skipped(id) { format!("{label} ({})", tl!("Safe Mode")) } else { label } +} + +/// The tooltip of an effect row Safe Mode skips: why it draws nothing, and its plug-in's id; +/// `None` for an effect that draws. +pub fn skipped_effect_tip(id: &str) -> Option { + let plugin = vectorcraft_plugins::effect::plugin_id(id).filter(|_| vectorcraft_plugins::effect::skipped(id))?; + let why = crate::i18n::msg(&vectorcraft_plugins::Error::SafeMode.to_string()); + Some(format!("{why}\n{}", vectorcraft_plugins::safe_mode::display_name(plugin))) +} + /// "Opacity: Default" or "Opacity: 50% Multiply". pub fn opacity_text(opacity: f32, blend: BlendMode) -> String { if (opacity - 1.0).abs() < 1e-4 && blend == BlendMode::Normal { @@ -582,7 +597,7 @@ fn default_stack(app: &mut VectorcraftApp, ui: &mut Ui) { let fx = |ui: &mut Ui, e: &Effect, id: (Option, usize)| { let (r, _) = row(ui, false); eye(ui, r, ("ap-def-fx", id), e.visible, false); - text(ui, pos2(r.left() + EYE_W + 24.0 + if id.0.is_some() { 12.0 } else { 0.0 }, r.center().y), &shown_effect_label(&e.id), false); + text(ui, pos2(r.left() + EYE_W + 24.0 + if id.0.is_some() { 12.0 } else { 0.0 }, r.center().y), &effect_row_label(&e.id), false); icons::paint(ui, "dc-fx", Rect::from_center_size(r.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)), t.icon); }; for (i, it) in ap.items.iter().enumerate().rev() { @@ -856,7 +871,7 @@ fn drag_ghost(app: &VectorcraftApp, ui: &Ui, n: &Node, what: Dragged, order: &[u Dragged::Effect(item, k) => { let Some(e) = n.appearance.effects_at(item).and_then(|fx| fx.get(k)) else { return }; let indent = if item.is_some() { 12.0 } else { 0.0 }; - text(ui, pos2(r.left() + indent + EYE_W + 24.0, y), &shown_effect_label(&e.id), false); + text(ui, pos2(r.left() + indent + EYE_W + 24.0, y), &effect_row_label(&e.id), false); } Dragged::Contents => { if let Some(label) = n.contents_label() { @@ -1128,7 +1143,11 @@ fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: usi // The name edits the effect on a click; the empty space takes the click rules (in `stack`) // and edits it on a double-click: its settings dialog, or the inline editor of an effect // without settings. - let name = name_link(ui, pos2(lx, r.center().y), ("ap-fx-link", item, k), &shown_effect_label(&e.id)); + let name = name_link(ui, pos2(lx, r.center().y), ("ap-fx-link", item, k), &effect_row_label(&e.id)); + let name = match skipped_effect_tip(&e.id) { + Some(tip) => name.on_hover_text(tip), + None => name, + }; let resp = edit_tip(resp, &name); let edit = name.clicked() || resp.double_clicked(); if edit { diff --git a/crates/ui-egui/src/panels/asset_export.rs b/crates/ui-egui/src/panels/asset_export.rs index 872bcc0d6..021bc848c 100644 --- a/crates/ui-egui/src/panels/asset_export.rs +++ b/crates/ui-egui/src/panels/asset_export.rs @@ -7,6 +7,10 @@ //! desktop with Background Export on, on the background thread); the web downloads them. The //! screen button opens Export for Screens on its Assets tab. //! +//! Export 3D Object (the panel menu) collects the selected objects with a 3D effect as 3D assets, +//! marked 3D on their tiles. While the panel's assets include 3D assets, a Format row picks their +//! mesh format (USDA, USDZ, glTF or OBJ), and Export writes them as mesh files beside the pictures. +//! //! Everything runs through the `assets.*` commands. use std::cell::RefCell; @@ -17,12 +21,12 @@ use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::{ExportAsset, Node, NodeId}; use vectorcraft_engine::DocState; -use vectorcraft_engine::cmd::fileio::{SCREEN_SETTINGS_KEYS, screen_export_preset_list}; +use vectorcraft_engine::cmd::fileio::{FORMATS_3D, SCREEN_SETTINGS_KEYS, format_3d_label, screen_export_preset_list}; use super::{pstate, set_pstate}; use crate::dialogs::{ - EXPORT_FOR_SCREENS, ScreenPresetPick, open_export_for_screens_assets, screen_export_preset, screen_formats, screen_import_presets, - screen_preset_list, screen_saved_options, screen_saved_rows, + EXPORT_FOR_SCREENS, ScreenPresetPick, drop_unread_screen_settings, open_export_for_screens_assets, screen_export_preset, screen_formats, + screen_import_presets, screen_preset_list, screen_saved_options, screen_saved_rows, }; use crate::state::Dialog; use crate::theme::Tokens; @@ -131,6 +135,11 @@ fn tiles(ui: &mut Ui, st: &DocState, sel: &[u64], ev: &mut Events) { let th = Rect::from_min_size(egui::pos2(r.center().x - THUMB / 2.0, r.top() + 4.0), vec2(THUMB, THUMB)); paint_thumb(ui, st, a, th); ui.painter().rect_stroke(th, 0.0, Stroke::new(if on { 2.0 } else { 1.0 }, if on { t.accent } else { t.border }), StrokeKind::Outside); + if a.format_3d.is_some() { + let badge = Rect::from_min_size(th.left_top() + vec2(2.0, 2.0), vec2(18.0, 12.0)); + ui.painter().rect_filled(badge, 2.0, t.accent); + ui.painter().text(badge.center(), egui::Align2::CENTER_CENTER, "3D", egui::FontId::proportional(9.0), Color32::WHITE); + } let name_rect = Rect::from_min_max(egui::pos2(r.left() + 2.0, th.bottom() + 3.0), egui::pos2(r.right() - 2.0, r.bottom() - 1.0)); match &renaming { Some((id, buf)) if *id == a.id => { @@ -214,6 +223,12 @@ fn export(app: &mut VectorcraftApp, ids: Vec, files_each: usize) { } fn export_now(app: &mut VectorcraftApp, ids: Vec, files_each: usize) { + // 3D assets are written as mesh files (`assets.export3D`), the others as pictures. + let Some(st) = app.session.active() else { return }; + let format = |id: &u64| st.doc.asset(*id).and_then(|a| a.format_3d.clone()); + let (three_d, ids): (Vec, Vec) = ids.into_iter().partition(|id| format(id).is_some()); + // An OBJ is two files. + let mesh_files: usize = three_d.iter().map(|id| if format(id).as_deref() == Some("obj") { 2 } else { 1 }).sum(); let mut p = json!({ "assets": ids }); // Match the shared dialog's TIFF defaults, retaining saved choices and other formats. if let Some(st) = app.session.active() @@ -225,7 +240,10 @@ fn export_now(app: &mut VectorcraftApp, ids: Vec, files_each: usize) { let saved = &st.doc.export_settings; let mut settings = saved.get("settings").and_then(Value::as_object).cloned().unwrap_or_default(); settings.insert("tiff".into(), Value::Object(screen_saved_options(app, saved, "tiff"))); - p["settings"] = Value::Object(settings); + // Without the keys the export no longer reads, which it would refuse. + let mut given = serde_json::Map::from_iter([("settings".to_string(), Value::Object(settings))]); + drop_unread_screen_settings(&mut given); + p["settings"] = given.remove("settings").unwrap_or_default(); } if io::is_web(app) { p["zip"] = json!(ids.len() * files_each > 1); @@ -251,7 +269,23 @@ fn export_now(app: &mut VectorcraftApp, ids: Vec, files_each: usize) { p["folder"] = json!(folder); p["openLocation"] = json!(saved.get("openLocation").and_then(Value::as_bool).unwrap_or(true)); } - if let Err(e) = io::export_files(app, "assets.export", p) { + let mut meshes = json!({ "assets": three_d }); + if io::is_web(app) { + meshes["zip"] = json!(mesh_files > 1); + } else { + meshes["folder"] = p["folder"].clone(); + meshes["openLocation"] = p["openLocation"].clone(); + } + // The meshes are written at once, before the pictures start on the background thread. When + // they fail, the pictures are not exported and the status bar shows the error. + if !three_d.is_empty() + && let Err(e) = io::export_files(app, "assets.export3D", meshes) + { + return app.status(e); + } + if !ids.is_empty() + && let Err(e) = io::export_files(app, "assets.export", p) + { app.status(e); } } @@ -264,6 +298,21 @@ enum Settings { Format(String), } +/// The Format of the 3D assets among `ids`: a row shown only when there are some, set to their +/// format (blank when they differ). Returns those assets and the format picked for them. +fn format_3d(ui: &mut Ui, st: &DocState, ids: &[u64]) -> Option<(Vec, &'static str)> { + let three_d: Vec<(u64, &str)> = ids.iter().filter_map(|id| Some((*id, st.doc.asset(*id)?.format_3d.as_deref()?))).collect(); + let first = three_d.first()?.1; + let current = if three_d.iter().all(|(_, f)| *f == first) { format_3d_label(first) } else { "" }; + let labels: Vec<&str> = FORMATS_3D.iter().map(|f| format_3d_label(f)).collect(); + let mut picked = None; + ui.horizontal(|ui| { + widgets::dim_label(ui, &format!("3D {}", tl!("Format:"))); + picked = widgets::dropdown_names(ui, "asset-export-3d-format", current, &labels, 90.0).and_then(|i| FORMATS_3D.get(i).copied()); + }); + picked.map(|f| (three_d.into_iter().map(|(id, _)| id).collect(), f)) +} + /// The document's shared export format rows. Returns the change asked for and how many files one asset makes. fn settings(ui: &mut Ui, st: &DocState) -> (Option, usize) { let (rows, preset) = screen_saved_rows(&st.doc.export_settings); @@ -316,6 +365,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.add_space(6.0); widgets::section_header(ui, tl!("Export Settings")); let (change, files_each) = settings(ui, st); + let all_ids: Vec = st.doc.assets.iter().map(|a| a.id).collect(); + let format_change = format_3d(ui, st, &sel_or_all(&sel, &all_ids)); let doc = st.uid; let mut save_name = pstate::>(&ctx, SAVING); let mut save_pick = None; @@ -392,6 +443,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if let Some(pick) = save_pick { preset(app, &ctx, doc, pick); } + if let Some((assets, format)) = format_change { + run(app, "assets.set3DFormat", json!({ "assets": assets, "format": format })); + } if add { collect(app, &ctx, None, alt); } @@ -481,6 +535,7 @@ fn preset_settings(app: &VectorcraftApp, ctx: &egui::Context, doc: u64) -> Value settings.remove("preset"); } settings.extend(shared); + drop_unread_screen_settings(&mut settings); Value::Object(settings) } @@ -503,6 +558,7 @@ fn shared_settings(app: &VectorcraftApp) -> Value { settings.insert("formats".into(), json!(rows)); } } + drop_unread_screen_settings(&mut settings); Value::Object(settings) } @@ -525,6 +581,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Add Selected Artwork as Multiple Assets"), has_art, false) { collect(app, &ctx, None, false); } + if menu_item(ui, tl!("Export 3D Object"), has_art, false) + && let Some(r) = run(app, "assets.add3D", json!({})) + { + let ids: Vec = r["assets"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect(); + set_pstate(&ctx, SELECTED, ids); + } ui.separator(); if menu_item(ui, tl!("Select All Assets"), !all.is_empty(), false) { set_pstate(&ctx, SELECTED, all.clone()); diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 9f9c66ba5..5f2f96a8c 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -1,12 +1,13 @@ //! Character panel: font family / style, size, leading, kerning, tracking, vertical and horizontal -//! scale, baseline shift, character rotation and the All Caps, Small Caps, Superscript, Subscript, -//! Underline and Strikethrough toggles and the Language menu. +//! scale, baseline shift, character rotation, the anti-aliasing method and the All Caps, Small Caps, +//! Superscript, Subscript, Underline and Strikethrough toggles and the Language menu. use std::sync::{Mutex, OnceLock}; use egui::{Key, Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::setup::{LANGUAGES, char_language_name}; +use vectorcraft_doc::text::TypeAntiAlias; use vectorcraft_doc::{CharStyle, NodeId, NodeKind}; use vectorcraft_tools::{Mods, ToolKey}; @@ -758,10 +759,11 @@ fn cell(ui: &mut Ui, label: &str, tip: &str, add: impl FnOnce(&mut Ui)) { }); } -/// The Character panel's grid of numeric fields, `w` wide: Font Size, Leading, Kerning and -/// Tracking, then with `more` the scales, Baseline Shift and Character Rotation (`s`: the style -/// shown). With Show Font Height Options on, a row under Font Size holds the Set Font Height -/// Reference dropdown. The Properties panel shows its first rows. +/// The Character panel's grid of fields, `w` wide: Font Size, Leading, Kerning and Tracking, +/// then with `more` the scales, Baseline Shift and Character Rotation, and a row with the +/// Anti-aliasing Method dropdown under Character Rotation (`s`: the style shown). With Show Font +/// Height Options on, a row under Font Size holds the Set Font Height Reference dropdown. The +/// Properties panel shows its first rows. pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &CharStyle, w: f32, more: bool) { let fw = ((w - 66.0) / 2.0).clamp(60.0, 100.0); let unit = app.session.type_unit(); @@ -836,9 +838,62 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: & } }); ui.end_row(); + // Anti-aliasing Method, under Character Rotation. + ui.label(""); + cell(ui, "aa", tl!("Anti-aliasing Method"), |ui| anti_alias_method(app, ui, fw)); + ui.end_row(); }); } +/// The Anti-aliasing Method dropdown, `width` wide: how the edges of the type objects the panel acts +/// on are drawn as pixels ([`set_anti_alias`]), blank where their methods differ. +fn anti_alias_method(app: &mut VectorcraftApp, ui: &mut Ui, width: f32) { + // The labels of [`TypeAntiAlias::ALL`], in its order. "Sharp" is looked up under the + // `antiAlias` context: the catalogs' plain "Sharp" row is the Contour tool's corner. + let sharp = crate::i18n::tr_ctx(crate::i18n::current(), "antiAlias", "Sharp"); + let labels = [tl!("None"), sharp, tl!("Crisp"), tl!("Strong")]; + let current = shared_anti_alias(app); + let shown = TypeAntiAlias::ALL.iter().zip(labels).find(|(m, _)| Some(**m) == current).map_or("", |(_, l)| l); + if let Some(m) = widgets::dropdown_names(ui, "ch-anti-alias", shown, &labels, width).and_then(|i| TypeAntiAlias::ALL.get(i)) { + set_anti_alias(app, *m); + } +} + +/// The anti-aliasing method of the type objects the panel acts on: the one the Type tool edits, +/// else the selected type (groups' included). `None` where they differ, or with no type. +fn shared_anti_alias(app: &VectorcraftApp) -> Option { + let st = app.session.active()?; + let mut acc = Shared::None; + match text_editing(app) { + Some((id, _, _)) => { + if let Some(NodeKind::Text(t)) = st.doc.node(id).map(|n| &n.kind) { + acc.add(t.anti_alias); + } + } + None => { + for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { + n.walk(&mut |c| { + if let NodeKind::Text(t) = &c.kind { + acc.add(t.anti_alias); + } + }); + } + } + } + acc.value() +} + +/// Set the anti-aliasing method `m` of the frame the Type tool edits, whatever part of its text +/// is selected, else the selected type objects (`text.setFormat {antiAlias}`). +fn set_anti_alias(app: &mut VectorcraftApp, m: TypeAntiAlias) { + let mut p = json!({"antiAlias": m.id()}); + if let Some((id, _, _)) = text_editing(app) { + end_typing(app); + p["ids"] = json!([id.0]); + } + app.run("text.setFormat", p).ok(); +} + /// The Font Size combo (Character and Properties panels, Control bar): typed, stepped, scrubbed or /// picked from the presets, in the type unit, blank where the selected text's sizes differ. pub(crate) fn size_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width: f32) { @@ -1380,14 +1435,22 @@ fn fractional_widths_on(app: &VectorcraftApp) -> bool { first_selected_text(app).is_none_or(|t| t.fractional_widths) } -/// Reset Panel: the characters the panel acts on ([`char_cmd`]) take tracking 0, Auto leading and +/// Reset Panel, in one undo step: the type objects the panel acts on take the Sharp anti-aliasing +/// method, and the characters the panel acts on ([`char_cmd`]) take tracking 0, Auto leading and /// kerning, no baseline shift, 100% scales, no rotation, no underline or strikethrough, All Caps /// and Small Caps off, the normal position (neither superscript nor subscript), Roman Baseline /// character alignment, Proportional Metrics, Tate-chu-yoko, Standard Vertical Roman Alignment and /// No Break off, Tate-chu-yoko Settings Up/Down and Left/Right 0, the Default character direction, /// Kashidas on, the Default digits and the Em Box font height reference, whether or not Show East Asian -/// Options, Show Indic Options and Show Font Height Options are on. +/// Options, Show Indic Options and Show Font Height Options are on. Inside an undo group that is +/// already open, the reset is part of that group's step. fn reset_panel(app: &mut VectorcraftApp) { + // Typing in progress stays an undo step of its own. + end_typing(app); + let open = app.session.active().is_some_and(|st| st.undo_group.is_some()); + if !open { + app.session.begin_named_undo_group(Some("Reset Panel".into())); + } let d = CharStyle::default(); style(app, json!({"tracking": 0, "leading": "auto"})); format( @@ -1401,6 +1464,12 @@ fn reset_panel(app: &mut VectorcraftApp) { "digits": d.digits, "fontHeightReference": d.font_height_reference }), ); + set_anti_alias(app, TypeAntiAlias::Sharp); + // Closes the active document's undo group only, so an undo group open in another document + // stays open. + if !open && let Some(st) = app.session.active_mut() { + st.undo_group = None; + } } #[cfg(test)] diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs index 577a081ef..6b948ce41 100644 --- a/crates/ui-egui/src/panels/character/tests.rs +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -561,6 +561,114 @@ fn small_caps_superscript_and_subscript_menu_items_set_and_check_the_text() { } } +/// The Anti-aliasing Method dropdown under Character Rotation shows the method of the selected +/// type, blank where the selected objects differ, and a pick sets it for the whole object, also +/// while the Type tool has part of its text selected. Reset Panel sets Sharp. +#[test] +fn the_anti_aliasing_method_dropdown_shows_and_sets_the_types_method() { + use super::super::tests_appearance::{click, frame_events, text_rect}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Hamburg", "size": 12})).unwrap()["id"].as_u64().unwrap(); + let method = |app: &VectorcraftApp, id: u64| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.anti_alias, + _ => panic!("text"), + }; + let ctx = egui::Context::default(); + let texts = |app: &mut VectorcraftApp| frame_events(&ctx, app, vec![], show); + let shown = |app: &mut VectorcraftApp| { + texts(app).into_iter().map(|(t, _)| t).filter(|t| ["None", "Sharp", "Crisp", "Strong"].contains(&t.as_str())).collect::>() + }; + assert_eq!(shown(&mut app), ["Sharp"]); + // Open the dropdown and pick Crisp. + let at = text_rect(&texts(&mut app), "Sharp").center(); + click(&ctx, &mut app, at, show); + let listed = texts(&mut app); + let crisp = text_rect(&listed, "Crisp").center(); + click(&ctx, &mut app, crisp, show); + assert_eq!(method(&app, a), TypeAntiAlias::Crisp); + assert_eq!(shown(&mut app), ["Crisp"]); + // Another object with Sharp selected as well: blank. + let b = app.run("text.create", json!({"x": 20, "y": 120, "text": "Bremen", "size": 12})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + assert!(shown(&mut app).is_empty(), "the methods differ"); + // The Type tool with two letters selected: the whole object takes the method. + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 1, "end": 3})); + assert!(text_editing(&app).is_some()); + set_anti_alias(&mut app, TypeAntiAlias::Strong); + assert_eq!(method(&app, a), TypeAntiAlias::Strong); + assert_eq!(method(&app, b), TypeAntiAlias::Sharp, "the other selected type is unchanged while editing"); + assert_eq!(shared_anti_alias(&app), Some(TypeAntiAlias::Strong)); + // Reset Panel sets Sharp, with the characters' attributes, in one undo step. + range_style(&mut app, json!({"tracking": 50})); + let tracking = |app: &VectorcraftApp| shared(app, |s| s.tracking); + assert_eq!(tracking(&app), Some(50.0)); + reset_panel(&mut app); + assert_eq!((method(&app, a), tracking(&app)), (TypeAntiAlias::Sharp, Some(0.0))); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!((method(&app, a), tracking(&app)), (TypeAntiAlias::Strong, Some(50.0)), "one undo step"); +} + +/// The Sharp method is named by its own `antiAlias` catalog row, not by the plain "Sharp" row +/// that names the Contour tool's corner. +#[test] +fn the_sharp_method_takes_its_own_translation() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.run("text.create", json!({"x": 20, "y": 50, "text": "Hamburg", "size": 12})).unwrap(); + let de = crate::i18n::Lang::from_code("de").unwrap(); + let (method, corner) = (crate::i18n::tr_ctx(de, "antiAlias", "Sharp"), crate::i18n::tr(de, "Sharp")); + assert_ne!(method, corner, "the catalog names the two differently"); + crate::i18n::set_current(de); + let text = crate::tests_labels::painted_text(&mut app, show); + crate::i18n::set_current(crate::i18n::Lang::EN); + assert!(text.contains(method) && !text.contains(corner), "{text}"); +} + +/// Reset Panel closes only the undo group it opens: in an undo group that is already open, +/// the reset is part of that group's step, and an undo group open in another document stays +/// open, with that document's later edits part of its step. +#[test] +fn reset_panel_closes_only_its_own_undo_group() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "A"})).unwrap()["id"].clone(); + let steps = |st: &vectorcraft_engine::DocState| (st.history.undo.len(), st.history.undo.last().map(|e| e.label.clone())); + // An undo group open in the same document (MCP `command_batch` with `atomic`). + app.run("edit.undoGroup.begin", json!({"label": "Batch"})).unwrap(); + app.run("text.setFormat", json!({"id": a, "antiAlias": "crisp"})).unwrap(); + reset_panel(&mut app); + assert!(app.session.active().unwrap().undo_group.is_some(), "the batch's undo group stays open"); + app.run("text.setFormat", json!({"id": a, "antiAlias": "strong"})).unwrap(); + app.run("edit.undoGroup.end", json!({})).unwrap(); + let a_steps = steps(app.session.active().unwrap()); + assert_eq!(a_steps.1, Some("Batch".to_string()), "the reset is part of the Batch step"); + // An undo group open in another document. The second document is open before the group + // begins, so switching to it opens no group there. + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + assert_eq!(app.session.active_index(), Some(1)); + let b = app.run("text.create", json!({"x": 20, "y": 50, "text": "B"})).unwrap()["id"].clone(); + app.session.set_active(0); + app.run("edit.undoGroup.begin", json!({"label": "Batch"})).unwrap(); + app.run("text.setFormat", json!({"id": a, "antiAlias": "none"})).unwrap(); + let a_steps = steps(app.session.active().unwrap()); + app.session.set_active(1); + assert!(app.session.active().unwrap().undo_group.is_none()); + app.run("select.set", json!({"ids": [b]})).unwrap(); + let b_steps = steps(app.session.active().unwrap()).0; + reset_panel(&mut app); + assert_eq!(steps(app.session.active().unwrap()), (b_steps + 1, Some("Reset Panel".to_string()))); + assert!(app.session.active().unwrap().undo_group.is_none()); + assert!(app.session.documents()[0].undo_group.is_some(), "the other document's undo group stays open"); + app.session.set_active(0); + app.run("text.setFormat", json!({"id": a, "antiAlias": "crisp"})).unwrap(); + app.run("edit.undoGroup.end", json!({})).unwrap(); + assert_eq!(steps(app.session.active().unwrap()), a_steps, "the later edit is part of the Batch step"); +} + /// Character panel menu › System Layout is off for a new document, and a click turns whole-point /// spacing on and back off for every text object. #[test] diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 643a7ff66..e58ca56f6 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -354,7 +354,7 @@ fn to32(c: &Color) -> Color32 { } /// The global or spot swatch a colour is a tint of (Tint mode). -struct Tint { +pub(crate) struct Tint { swatch: String, spot: bool, /// 0..1. @@ -375,20 +375,20 @@ impl Tint { /// What the panel is editing: the active proxy's solid colour or a selected gradient stop (with /// the global swatch it is a tint of), or a selected freeform point. -enum Target { +pub(crate) enum Target { Paint(Option, Option), Stop { paint: Paint, index: usize, color: Color, tint: Option }, Point { index: usize, color: Color }, } impl Target { - fn color(&self) -> Option { + pub(crate) fn color(&self) -> Option { match self { Target::Paint(c, _) => *c, Target::Stop { color, .. } | Target::Point { color, .. } => Some(*color), } } - fn tint(&self) -> Option<&Tint> { + pub(crate) fn tint(&self) -> Option<&Tint> { match self { Target::Paint(_, t) | Target::Stop { tint: t, .. } => t.as_ref(), Target::Point { .. } => None, @@ -425,9 +425,14 @@ fn target_with(app: &VectorcraftApp, mixed: bool) -> Target { } } +/// [`target`] for the Color Wheel panel, which edits what this panel's sliders edit. +pub(crate) fn wheel_target(app: &VectorcraftApp, ui: &Ui) -> Target { + target(app, ui) +} + /// Apply `c` to the target, or with `behind` (an Alt-click) to the inactive proxy as a solid /// colour, keeping the active proxy in front. -fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind: bool) { +pub(crate) fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind: bool) { match tgt { Target::Stop { paint: Paint::Gradient(g), index, .. } if !behind => { let mut stops = g.gradient.stops.clone(); diff --git a/crates/ui-egui/src/panels/color_wheel.rs b/crates/ui-egui/src/panels/color_wheel.rs new file mode 100644 index 000000000..a2108d8cf --- /dev/null +++ b/crates/ui-egui/src/panels/color_wheel.rs @@ -0,0 +1,166 @@ +//! Color Wheel panel (Window › Color Wheel): the Fill and Stroke proxy beside a hue ring around a +//! square with saturation across and brightness up at the ring's hue. The wheel edits what the +//! Color panel's sliders edit (the active proxy's color, the selected gradient stop or freeform +//! point) through the same paint commands, and a drag is one undo step. Alt paints the inactive +//! proxy and Shift steps the ring by 45°. A pick is display sRGB, converted into the working RGB +//! space: an RGB, CMYK or Lab color keeps its model, and a Gray color or a tint of a global swatch +//! becomes a process color in the document's color mode. The HSB picked last stays while the +//! objects, the proxy, the color and the color settings are unchanged, so a gray keeps its hue. + +use egui::{Ui, UiBuilder}; +use vectorcraft_color::cms::{self, Cms}; +use vectorcraft_color::{Color, Paint, hsb_to_rgb, rgb_to_hsb}; +use vectorcraft_doc::{ColorMode, NodeId}; + +use super::color::{Target, apply, wheel_target}; +use super::{pstate, set_pstate}; +use crate::VectorcraftApp; +use crate::widgets::{self, FieldAxis, Live}; + +pub const ID: &str = "colorWheel"; +/// The widest the wheel is drawn. +const MAX_WHEEL: f32 = 200.0; +/// Panel state: the HSB picked last, its [`Owner`] and the display sRGB of the color it stored. +const MEMORY: &str = "color-wheel-hsb"; + +/// What the HSB picked last belongs to: the document, the selected objects or text characters, +/// the appearance item, the proxy, the gradient stop or freeform point, its paint, and the color +/// the pick stored. The wheel shows that HSB only while all of them are unchanged. +#[derive(Clone, Debug, PartialEq)] +struct Owner { + doc: Option, + selection: Vec, + /// The Type tool's selected characters, when it edits the paint. + text_selection: Option<(NodeId, usize, usize)>, + /// The Appearance panel's active fill or stroke row. + appearance_item: Option, + fill: bool, + /// The selected gradient stop (`Some((false, i))`) or freeform point (`Some((true, i))`). + slot: Option<(bool, usize)>, + /// The gradient behind a selected stop or point, including its other colors. + gradient: Option, + color: Color, +} + +impl Owner { + fn of(app: &VectorcraftApp, tgt: &Target, color: Color) -> Self { + let st = app.session.active(); + let slot = match tgt { + Target::Paint(..) => None, + Target::Stop { index, .. } => Some((false, *index)), + Target::Point { index, .. } => Some((true, *index)), + }; + Self { + doc: st.map(|d| d.uid), + selection: st.map(|d| d.selection.objects.clone()).unwrap_or_default(), + text_selection: super::character::text_editing(app), + appearance_item: app.session.appearance_item(), + fill: app.session.fill_active, + slot, + gradient: slot.map(|_| super::active_paint(app)), + color, + } + } +} + +/// The HSB a color shows on the wheel: that of its display sRGB through `cms`. +pub(crate) fn display_hsb(cms: &Cms, c: &Color) -> [f32; 3] { + rgb_to_hsb(cms.display_rgb(c)) +} + +/// The HSB the wheel shows for `c` through `cms` when `hsb` was picked last and `c` then displayed +/// as the sRGB `shown`: `hsb` while `c` still displays as `shown`, else `c`'s own display HSB (after +/// a change of the color settings or the document's profile). +pub(crate) fn remembered_hsb(cms: &Cms, c: &Color, shown: [f32; 3], hsb: [f32; 3]) -> [f32; 3] { + if cms.display_rgb(c) == shown { hsb } else { display_hsb(cms, c) } +} + +/// The color a pick of display HSB `hsb` stores in place of `was`, through `cms`. The picked sRGB +/// goes into the working RGB space. An RGB color stays RGB, and a CMYK or Lab color converts to its +/// model with the color settings. Anything else (no color, a Gray color, a tint of a global swatch) +/// becomes a process color in the document's color mode: managed CMYK when `cmyk_doc`, else RGB. +pub(crate) fn stored(cms: &Cms, hsb: [f32; 3], was: Option<&Color>, cmyk_doc: bool) -> Color { + let [r, g, b] = cms.srgb_to_rgb(hsb_to_rgb(hsb)); + let rgb = Color::rgb(r, g, b); + let cmyk = || { + let [c, m, y, k] = cms.to_cmyk(&rgb, cms.settings().intent); + Color::cmyk(c, m, y, k) + }; + match was { + Some(Color::Rgb { .. }) => rgb, + Some(Color::Cmyk { .. }) => cmyk(), + Some(Color::Lab { .. }) => { + let l = cms.lab(&rgb); + Color::lab(l.l, l.a, l.b) + } + _ if cmyk_doc => cmyk(), + _ => rgb, + } +} + +/// The color a pick replaces, which sets the model of the new one: the target's, or with Alt +/// (`behind`) the inactive proxy's solid color. None for a tint of a global swatch. +fn replaced(app: &VectorcraftApp, tgt: &Target, behind: bool) -> Option { + if !behind { + return tgt.color().filter(|_| tgt.tint().is_none()); + } + let (fill, stroke) = super::current_paints(app); + match if app.session.fill_active { stroke } else { fill } { + Paint::Solid { color, swatch, .. } => { + let global = swatch.is_some_and(|n| app.session.active().is_some_and(|d| d.doc.global_color(&n).is_some())); + (!global).then_some(color) + } + _ => None, + } +} + +pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + let tgt = wheel_target(app, ui); + let color = tgt.color(); + let cms = cms::active(); + // With None, a pattern or a "?" proxy the wheel shows the last solid color without markers, + // and a click, an arrow key or a screen reader's action applies the picked color. + let hsb = match (color, pstate::>(ui.ctx(), MEMORY)) { + (Some(c), Some((owner, shown, hsb))) if owner == Owner::of(app, &tgt, c) => remembered_hsb(&cms, &c, shown, hsb), + (c, _) => display_hsb(&cms, &c.unwrap_or(app.session.last_solid)), + }; + let marked = color.is_some(); + let mut pick: Option<([f32; 3], Live)> = None; + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(44.0); + super::proxy(app, ui, 40.0); + }); + let size = ui.available_width().min(MAX_WHEEL); + let ring = widgets::hue_ring(ui, "color-wheel-ring", tl!("Hue ring"), size, hsb[0], marked); + let square = widgets::ring_square(ring.rect); + let mut field_ui = ui.new_child(UiBuilder::new().id_salt("color-wheel-square").max_rect(square)); + let axes = + [FieldAxis { name: tl!("Saturation"), units: 100.0, suffix: "%" }, FieldAxis { name: tl!("Brightness"), units: 100.0, suffix: "%" }]; + let at = |x: f32, y: f32| widgets::hsb32([hsb[0], x, 1.0 - y]); + let name = tl!("Saturation and brightness"); + let (field, field_phase) = widgets::color_field(&mut field_ui, "field", (name, axes), square.size(), (hsb[1], 1.0 - hsb[2]), marked, &at); + if let Some((h, phase)) = ring.hue { + pick = Some(([h, hsb[1], hsb[2]], phase)); + } + // A press between the square and the ring picks the closest point of the square, which + // takes the keyboard focus. + if let Some((p, phase)) = ring.inner { + let field_id = widgets::id_with(field_ui.scope_id(), "field"); + field_ui.memory_mut(|m| m.request_focus(field_id)); + let f = |v: f32, lo: f32, len: f32| ((v - lo) / len).clamp(0.0, 1.0); + pick = Some(([hsb[0], f(p.x, square.left(), square.width()), 1.0 - f(p.y, square.top(), square.height())], phase)); + } + if let Some((x, y)) = field { + pick = Some(([hsb[0], x, 1.0 - y], field_phase)); + } + }); + let Some((hsb, phase)) = pick else { return }; + let behind = super::alt_held(ui); + let cmyk_doc = app.session.active().is_some_and(|d| d.doc.color_mode == ColorMode::Cmyk); + let c = stored(&cms, hsb, replaced(app, &tgt, behind).as_ref(), cmyk_doc); + apply(app, &tgt, c, phase, behind); + if !behind { + set_pstate(ui.ctx(), MEMORY, Some((Owner::of(app, &tgt, c), cms.display_rgb(&c), hsb))); + } +} diff --git a/crates/ui-egui/src/panels/gradient/tests.rs b/crates/ui-egui/src/panels/gradient/tests.rs index e27da3cfd..9177f315e 100644 --- a/crates/ui-egui/src/panels/gradient/tests.rs +++ b/crates/ui-egui/src/panels/gradient/tests.rs @@ -106,13 +106,14 @@ fn app(fill: Value) -> VectorcraftApp { struct Panel { ctx: egui::Context, time: f64, + scope: egui::Id, } impl Panel { fn new() -> Self { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); - Self { ctx, time: 0.0 } + Self { ctx, time: 0.0, scope: egui::Id::NULL } } /// One frame with `events` (and `modifiers` held); returns the texts drawn and where. @@ -144,23 +145,27 @@ impl Panel { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(260.0, 600.0))), ..Default::default() }; - let mut out = self.ctx.run_ui(input, |ui| draw(app, ui)); + let mut out = self.ctx.run_ui(input, |ui| { + self.scope = ui.scope_id(); + draw(app, ui); + }); out.textures_delta.clear(); let mut v = vec![]; out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); v } - /// The rect of the widget keyed `key` (last frame). egui 0.37's `WidgetRect.parent_id` is - /// the parent Ui's unstable `unique_id`, not the stable `scope_id` widgets are salted with; - /// debug builds remember the `with(key)` that made each id, so match that. - fn widget(&self, key: impl std::hash::Hash + std::fmt::Debug + Copy) -> Rect { - let suffix = format!(".with({key:?})"); - self.ctx - .viewport(|vp| { - vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).find(|w| format!("{:?}", w.id).ends_with(&suffix)).map(|w| w.rect) - }) - .expect("widget drawn") + /// Match actual IDs in the panel or its immediate horizontal rows. `parent_id` is an + /// unstable auto ID, and the human-readable `Id` debug source is absent in release builds. + fn widget(&self, key: impl egui::AsIdSalt + Copy) -> Rect { + // Match Ui::new_child: its default child salt is an IdSalt, not the raw string. + let row_scope = self.scope.with(egui::IdSalt::new("child")); + let ids = [self.scope.with(key), row_scope.with(key)]; + // Outside run_ui, read_response can prefer the older this_pass geometry after + // end_pass swaps the passes. Use the completed frame, including after Hide Options. + let matches: Vec<_> = self.ctx.viewport(|vp| ids.into_iter().filter_map(|id| vp.prev_pass.widgets.get(id).map(|w| w.rect)).collect()); + assert_eq!(matches.len(), 1, "expected one widget for {key:?}"); + matches[0] } fn click(&mut self, app: &mut VectorcraftApp, at: Pos2) -> Vec<(String, Pos2)> { @@ -252,7 +257,7 @@ fn the_proxy_toggles_fill_and_stroke_and_stays_with_options_hidden() { let fill = p.widget("fill-proxy"); p.click(&mut app, fill.left_top() + vec2(2.0, 2.0)); assert!(app.session.fill_active, "the proxy works with the options hidden"); - p.widget(("grad-stop", 1)); + p.widget(("grad-stop", 1_usize)); } #[test] @@ -331,7 +336,7 @@ fn a_selected_diamond_shows_and_sets_its_midpoint_in_location() { let mut p = Panel::new(); let texts = p.frame(&mut app, vec![], Modifiers::NONE); assert!(texts.iter().any(|(t, _)| t == "100%"), "the stop's location: {texts:?}"); - let diamond = p.widget(("grad-mid", 0)); + let diamond = p.widget(("grad-mid", 0_usize)); let texts = p.click(&mut app, diamond.center()); assert_eq!(app.session.selected_stop(), None, "the diamond replaces the stop selection"); assert!(texts.iter().any(|(t, _)| t == "30%"), "the midpoint: {texts:?}"); @@ -343,7 +348,7 @@ fn a_selected_diamond_shows_and_sets_its_midpoint_in_location() { p.frame(&mut app, vec![key(egui::Key::Enter, Modifiers::NONE)], Modifiers::NONE); assert!((fill(&app).gradient.stops[0].midpoint - 0.6).abs() < 1e-6, "{:?}", fill(&app).gradient.stops); // Clicking a stop selects it again. - let stop = p.widget(("grad-stop", 0)); + let stop = p.widget(("grad-stop", 0_usize)); let texts = p.click(&mut app, stop.center()); assert_eq!(app.session.selected_stop(), Some(0)); assert!(texts.iter().any(|(t, _)| t == "0%")); @@ -474,7 +479,7 @@ fn alt_drag_copies_a_stop_and_alt_dropping_on_a_stop_swaps_colours() { let mut app = app(json!({"gradient": {}})); let mut p = Panel::new(); p.frame(&mut app, vec![], Modifiers::NONE); - let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center()); + let (a, b) = (p.widget(("grad-stop", 0_usize)).center(), p.widget(("grad-stop", 1_usize)).center()); let mid = pos2((a.x + b.x) / 2.0, a.y); let offsets = |app: &VectorcraftApp| fill(app).gradient.stops.iter().map(|s| (s.offset * 10.0).round() / 10.0).collect::>(); // A plain drag moves the stop. @@ -499,7 +504,7 @@ fn a_dropped_swatch_adds_or_recolours_a_stop() { let mut app = app(json!({"gradient": {}})); let mut p = Panel::new(); p.frame(&mut app, vec![], Modifiers::NONE); - let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center()); + let (a, b) = (p.widget(("grad-stop", 0_usize)).center(), p.widget(("grad-stop", 1_usize)).center()); let drop = |p: &mut Panel, app: &mut VectorcraftApp, at: Pos2| { egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::paint(Paint::solid(Color::rgb(1.0, 0.0, 0.0)))); p.frame(app, vec![Event::PointerMoved(at)], Modifiers::NONE); @@ -509,7 +514,7 @@ fn a_dropped_swatch_adds_or_recolours_a_stop() { drop(&mut p, &mut app, pos2((a.x + b.x) / 2.0, a.y - 20.0)); assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#000000"]); p.frame(&mut app, vec![], Modifiers::NONE); - let last = p.widget(("grad-stop", 2)).center(); + let last = p.widget(("grad-stop", 2_usize)).center(); drop(&mut p, &mut app, last); assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#ff0000"]); // A spot swatch tile dropped on a stop links it: editing the swatch recolours the stop. @@ -538,7 +543,7 @@ fn a_color_dropped_on_the_ramp_keeps_its_model_in_a_cmyk_document() { app.run("paint.setFill", json!({"gradient": {"stops": rgb}, "keepModel": true})).unwrap(); let mut p = Panel::new(); p.frame(&mut app, vec![], Modifiers::NONE); - let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center()); + let (a, b) = (p.widget(("grad-stop", 0_usize)).center(), p.widget(("grad-stop", 1_usize)).center()); let at = pos2((a.x + b.x) / 2.0, a.y - 20.0); let red = Color::rgb(1.0, 0.0, 0.0); egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::paint(Paint::solid(red))); @@ -554,7 +559,7 @@ fn double_clicking_a_stop_opens_the_shared_popover_beside_it() { let mut app = app(json!({"gradient": {}})); let mut p = Panel::new(); p.frame(&mut app, vec![], Modifiers::NONE); - let stop = p.widget(("grad-stop", 1)); + let stop = p.widget(("grad-stop", 1_usize)); let b = |pressed| Event::PointerButton { pos: stop.center(), button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; p.frame(&mut app, vec![Event::PointerMoved(stop.center()), b(true), b(false), b(true), b(false)], Modifiers::NONE); let d = app.ui.dialog.clone().expect("the popover opened"); @@ -755,7 +760,7 @@ fn a_stop_dragged_off_the_ramp_or_deleted_with_the_button_beside_it_goes() { let mut app = app(json!({"gradient": {"stops": stops}})); let mut p = Panel::new(); p.frame(&mut app, vec![], Modifiers::NONE); - let mid = p.widget(("grad-stop", 1)).center(); + let mid = p.widget(("grad-stop", 1_usize)).center(); // Dragged down off the ramp, the middle stop goes. p.drag(&mut app, mid, mid + vec2(0.0, 60.0), Modifiers::NONE); assert_eq!(hexes(&app), ["#ffffff", "#000000"]); @@ -773,7 +778,7 @@ fn a_stop_dragged_off_the_ramp_or_deleted_with_the_button_beside_it_goes() { assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#000000"]); app.run("edit.redo", json!({})).unwrap(); // Two stops stay two: dragged off, the stop comes back, and the button is off. - let first = p.widget(("grad-stop", 0)).center(); + let first = p.widget(("grad-stop", 0_usize)).center(); p.drag(&mut app, first, first + vec2(0.0, 60.0), Modifiers::NONE); assert_eq!(hexes(&app), ["#ffffff", "#000000"]); app.run("gradient.selectStop", json!({"index": 0})).unwrap(); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 65f7c6bc3..313242bb4 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -176,6 +176,9 @@ struct Out { column: Option>, /// Rows to open or close, and whether to open them. toggle: Vec<(NodeId, bool)>, + /// The highlighted rows after this frame's row click, when one happened: the click's action + /// runs after `delete_key`, so `view.rows` is still the state before it. + rows_after_click: Option>, } /// The panel state that says the Layers panel's rows have the keyboard ([`super::has_row_keys`]). @@ -361,12 +364,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // Keyboard: Delete removes the highlighted rows while the panel has the keyboard (a row was // clicked last, not the canvas). // The panel keeps it while it is drawn ([`super::has_row_keys`]); the app's shortcuts leave - // Delete and Backspace to it meanwhile ([`super::rows_take_delete`]). + // Delete and Backspace to it meanwhile ([`super::rows_take_delete`]). With no highlighted rows + // left (the last one was deleted) the panel gives the keys back, so Delete clears the selected + // art again instead of doing nothing. let pressed_outside = ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !list_rect.contains(p))); - let focus = super::has_row_keys(ui.ctx(), ROW_KEYS) && !pressed_outside; + // A click's highlight action runs after this block, so read the rows it will leave, not the + // snapshot `view.rows` took before it. + let rows = out.rows_after_click.as_deref().unwrap_or(&view.rows); + let focus = super::has_row_keys(ui.ctx(), ROW_KEYS) && !pressed_outside && !rows.is_empty(); super::set_row_keys(ui.ctx(), ROW_KEYS, focus); if focus - && !view.rows.is_empty() + && !rows.is_empty() && ui.ctx().memory(|m| m.focused().is_none()) && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace)) { @@ -376,6 +384,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { for (c, p) in out.actions { app.run(&c, p).ok(); } + // A deletion that took the last highlighted row leaves the panel nothing to delete. Release the + // keys now, not at the next draw, so the very next frame's Delete reaches Edit › Clear. + if app.session.active().is_some_and(|st| st.highlighted_rows().is_empty()) { + super::set_row_keys(ui.ctx(), ROW_KEYS, false); + } see_selection(app, ui, selected); } diff --git a/crates/ui-egui/src/panels/layers/rows.rs b/crates/ui-egui/src/panels/layers/rows.rs index 000a3c07e..376c021c4 100644 --- a/crates/ui-egui/src/panels/layers/rows.rs +++ b/crates/ui-egui/src/panels/layers/rows.rs @@ -91,6 +91,9 @@ pub(super) fn resolve_click(ui: &Ui, view: &View, list: &[Row], out: &mut Out) { if click.moves_anchor() { ui.data_mut(|d| d.insert_temp(key("anchor"), id)); } + let mut rows = view.rows.clone(); + widgets::apply_row_click(&mut rows, &click); + out.rows_after_click = Some(rows); let ids = |rows: &[NodeId]| rows.iter().map(|r| r.0).collect::>(); out.actions.push(match click { widgets::RowClick::Only(id) => ("layer.setCurrent".into(), json!({"id": id.0})), diff --git a/crates/ui-egui/src/panels/layers/tests.rs b/crates/ui-egui/src/panels/layers/tests.rs index 0bd0efdde..cad2530e5 100644 --- a/crates/ui-egui/src/panels/layers/tests.rs +++ b/crates/ui-egui/src/panels/layers/tests.rs @@ -494,14 +494,14 @@ fn rename_opens_the_name_field_of_the_selected_object() { assert_eq!(name(&app), before, "one undo step"); } -/// Rename (`ui.rename`) has no default key, F2 being free: it is listed in Keyboard Shortcuts, +/// Rename (`ui.rename`) has no default key (F2 is Cut's alternate on Windows and Linux): it is listed in Keyboard Shortcuts, /// where a key can be assigned to it. #[test] fn rename_has_no_default_key() { use crate::shortcut_editor::{command_for_key, default_command_shortcut}; crate::shortcut_editor::sync(&crate::state::UiState::default()); assert_eq!(default_command_shortcut("ui.rename"), None); - assert_eq!(command_for_key("F2"), None); + assert_ne!(command_for_key("F2"), Some("ui.rename")); let (mut app, _, _) = two_rects(); let listed = app.run("shortcuts.list", json!({"query": "ui.rename"})).unwrap(); let rows: Vec<_> = listed.as_array().expect("rows").iter().map(|r| r["label"].clone()).collect(); diff --git a/crates/ui-egui/src/panels/layers/thumbs.rs b/crates/ui-egui/src/panels/layers/thumbs.rs index 49ce4b61b..4fcdc03e4 100644 --- a/crates/ui-egui/src/panels/layers/thumbs.rs +++ b/crates/ui-egui/src/panels/layers/thumbs.rs @@ -266,18 +266,17 @@ impl Thumbs { return; } let open: HashSet = uids.iter().copied().collect(); - let closed = self.open_docs.as_ref().is_some_and(|before| before.iter().any(|uid| !open.contains(uid))); + let closed: HashSet = self.open_docs.as_ref().map(|before| before.difference(&open).copied().collect()).unwrap_or_default(); let stale = self.entries.keys().chain(self.pending.keys()).any(|key| !open.contains(&key.0)) || self.showing.is_some_and(|uid| !open.contains(&uid)); self.entries.retain(|key, _| open.contains(&key.0)); self.pending.retain(|key, _| open.contains(&key.0)); self.showing = self.showing.filter(|uid| open.contains(uid)); - if closed { + if !closed.is_empty() { // Images finished before now are dropped with the rest of the queue: the open - // documents' rows are asked for again. + // documents' rows are asked for again. Documents opened later keep their jobs. if let Some(w) = &self.worker { - let open = open.clone(); - w.forget(move |key| !open.contains(&key.0)); + w.forget(move |key| closed.contains(&key.0)); if w.poll().is_none() { self.worker = None; } @@ -536,6 +535,37 @@ mod tests { assert_eq!(done.key.0, next, "no worker keeps the closed document's image"); } + #[test] + fn a_document_opened_while_a_job_runs_after_a_close_gets_its_thumbnails() { + let ctx = egui::Context::default(); + let Some(worker) = ThumbWorker::start(&ctx, "test") else { return }; + let mut thumbs = Thumbs { worker: Some(worker), worker_started: true, ..Default::default() }; + thumbs.retain_open(&[1, 2]); + // A job of document 2 is running. + let (started, running) = std::sync::mpsc::channel::<()>(); + let (release, held) = std::sync::mpsc::channel::<()>(); + let blocker = (2, NodeId(1)); + let job: crate::thumb_worker::JobFn = Box::new(move |_| { + started.send(()).ok(); + held.recv().ok(); + None + }); + assert!(thumbs.worker.as_ref().is_some_and(|w| w.submit(blocker, (blocker, Weak::new(), 1), job))); + running.recv().expect("the job starts"); + // Document 1 closes, then document 3 opens and one of its rows is queued. + thumbs.retain_open(&[2]); + thumbs.retain_open(&[2, 3]); + let opened = (3, NodeId(2)); + assert!(thumbs.worker.as_ref().is_some_and(|w| w.submit(opened, (opened, Weak::new(), 1), Box::new(|_| None)))); + // A job of document 2 queued after it marks the end of the queue. + let last = (2, NodeId(3)); + assert!(thumbs.worker.as_ref().is_some_and(|w| w.submit(last, (last, Weak::new(), 1), Box::new(|_| None)))); + release.send(()).ok(); + let worker = thumbs.worker.as_ref().expect("the worker is kept"); + let order: Vec<(u64, NodeId)> = (0..3).map_while(|_| worker.wait().map(|d| d.key.0)).take_while(|key| *key != last).collect(); + assert_eq!(order, [blocker, opened], "document 3's job comes back before the last one"); + } + /// An open zigzag of `anchors` anchors. fn line(anchors: usize) -> PathData { let pts: Vec = (0..anchors).map(|i| Point::new(i as f64 * 0.01, (i % 2) as f64 * 50.0)).collect(); 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/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 8cb29ef30..32d15d715 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -1,13 +1,15 @@ //! Paragraph panel: seven alignment buttons, Paragraph Direction (with the Indic options), the -//! 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. +//! Bullets and Numbered List buttons and their presets (More Options opens Bullets and +//! Numbering…), 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…, 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}; @@ -218,7 +220,7 @@ fn visit_paragraphs(app: &VectorcraftApp, mut f: impl FnMut(&ParaStyle)) { } /// A distance shared by every paragraph in scope, or `None` when they differ (blank spin field). -fn shared_para_field(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> f64) -> Option { +pub(crate) fn shared_para_field(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> f64) -> Option { let mut acc = Shared::None; let mut any = false; visit_paragraphs(app, |p| { @@ -251,7 +253,7 @@ pub(crate) fn shared_single_word_justify(app: &VectorcraftApp) -> Option(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> T) -> Option { +pub(crate) fn shared_para(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> T) -> Option { let mut acc = Shared::None; let mut any = false; visit_paragraphs(app, |p| { @@ -465,6 +467,25 @@ pub(crate) fn apply_numbers(app: &mut VectorcraftApp, p: &Value) -> Result [(&'static str, &'static str, Option); 5] { + use vectorcraft_doc::text::Bullet; + [ + (tl!("None"), "none", None), + (tl!("Filled Dot"), "dot", Some(Bullet::Dot)), + (tl!("Dash"), "dash", Some(Bullet::Dash)), + (tl!("Hollow Circle"), "circle", Some(Bullet::Circle)), + (tl!("Filled Square"), "square", Some(Bullet::Square)), + ] +} + +/// The first `n` numbers of number style `style`, as the Numbered List presets show them ("1, 2, +/// 3"). +pub(crate) fn number_sample(style: vectorcraft_doc::text::NumberStyle, n: u32) -> String { + (1..=n).map(|k| style.format(k)).collect::>().join(", ") +} + /// Type › Bullets and Numbering › Convert to Text (`type.convertListToText`): the markers of the /// list paragraphs of the selected type, or of those the Type tool's selection touches, become /// characters of the text and the lists are turned off (`text.convertListToText`, which also moves @@ -477,9 +498,9 @@ pub(crate) fn convert_list_to_text(app: &mut VectorcraftApp) -> Result, checked: bool) -> bool { - use vectorcraft_doc::text::Bullet; let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(130.0, 22.0), egui::Sense::click()); if resp.hovered() { @@ -527,24 +545,53 @@ fn bullet_row(ui: &mut Ui, name: &str, bullet: Option painter.line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, -5.0)], stroke), - Some(Bullet::Dot) => painter.circle_filled(c, 3.0, t.text_strong), - Some(Bullet::Dash) => painter.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], stroke), - Some(Bullet::Circle) => painter.circle_stroke(c, 3.0, egui::Stroke::new(1.2, t.text_strong)), - Some(Bullet::Square) => painter.rect_filled(egui::Rect::from_center_size(c, vec2(6.0, 6.0)), 0.0, t.text_strong), - }; + paint_bullet(painter, rect.left_center() + vec2(28.0, 0.0), bullet, t.text_strong); painter.text(rect.left_center() + vec2(42.0, 0.0), egui::Align2::LEFT_CENTER, name, font, t.text_strong); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), checked, name)); resp.clicked() } +/// Draw `bullet` centered on `c` in `color` as the presets show it (a slash for None). +pub(crate) fn paint_bullet(painter: &egui::Painter, c: egui::Pos2, bullet: Option, color: egui::Color32) { + use vectorcraft_doc::text::Bullet; + let stroke = egui::Stroke::new(1.5, color); + match bullet { + None => painter.line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, -5.0)], stroke), + Some(Bullet::Dot) => painter.circle_filled(c, 3.0, color), + Some(Bullet::Dash) => painter.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], stroke), + Some(Bullet::Circle) => painter.circle_stroke(c, 3.0, egui::Stroke::new(1.2, color)), + Some(Bullet::Square) => painter.rect_filled(egui::Rect::from_center_size(c, vec2(6.0, 6.0)), 0.0, color), + }; +} + +/// More Options at the end of the Bullets and Numbered List presets, under a line. Whether it was +/// clicked. +fn more_options_row(ui: &mut Ui) -> bool { + let t = Tokens::get(ui.ctx()); + ui.separator(); + let (rect, resp) = ui.allocate_exact_size(vec2(130.0, 22.0), egui::Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(rect, egui::CornerRadius::same(4), t.hover); + } + let name = tl!("More Options"); + ui.painter().text(rect.left_center() + vec2(22.0, 0.0), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.5), t.text_strong); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), name)); + resp.clicked() +} + +/// More Options: open Bullets and Numbering on `tab` (`ui.bulletsAndNumbering`), or show why it +/// can't open. +fn more_options(app: &mut VectorcraftApp, tab: &str) { + if let Err(e) = app.run("ui.bulletsAndNumbering", json!({ "tab": tab })) { + app.status(e); + } +} + /// The Numbered List button and the arrow beside it (Paragraph panel). The button makes the /// paragraphs the panel acts on a numbered list (`text.setFormat {list: "number"}`) and shows /// pressed while they all are one. The arrow lists the presets (`{number}`), each shown by its -/// first three numbers: None turns the list off, and the one every paragraph has is checked. +/// first three numbers: None turns the list off, and the one every paragraph has is checked. More +/// Options after them opens Bullets and Numbering on its Numbers tab. pub fn numbers_button(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { use vectorcraft_doc::text::NumberStyle; let t = Tokens::get(ui.ctx()); @@ -559,22 +606,26 @@ pub fn numbers_button(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { crate::icons::paint(ui, "chevron-down", egui::Rect::from_center_size(rect.center(), vec2(10.0, 10.0)), t.text_dim); arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Numbered List"))); let arrow = arrow.on_hover_text(tl!("Numbered List")); + let mut more = false; let picked = egui::Popup::menu(&arrow).show(|ui| { let mut picked = None; if bullet_row(ui, tl!("None"), None, shown == Some(None)) { picked = Some(None); } for style in NumberStyle::ALL { - let sample = (1..=3).map(|n| style.format(n)).collect::>().join(", "); - if number_row(ui, &sample, shown == Some(Some(style))) { + if number_row(ui, &number_sample(style, 3), shown == Some(Some(style))) { picked = Some(Some(style)); } } + more = more_options_row(ui); picked }); if let Some(style) = picked.and_then(|r| r.inner) { format(app, json!({ "number": style.map_or(json!("none"), |s| json!(s)) })); } + if more { + more_options(app, "numbers"); + } } /// A row of the Numbered List presets: a check when `checked`, then the style's first numbers @@ -810,6 +861,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); @@ -826,13 +893,27 @@ 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, the Line-end Punctuation Half Width Mojikumi +/// Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Roman +/// Hanging Punctuation off, no kashidas and no tab stops, as one undo step named Reset Panel (inside +/// an undo group that is already open, as part of its step). fn reset_panel(app: &mut VectorcraftApp) { let h = HyphenationOptions::default(); let j = vectorcraft_doc::JustificationOptions::default(); + // The Type tool's typing session stays an undo step of its own. + super::character::end_typing(app); + let open = app.session.active().is_some_and(|st| st.undo_group.is_some()); + if !open { + app.session.begin_named_undo_group(Some("Reset Panel".into())); + } para_cmd(app, "text.setStyle", json!({"justify": "auto"})); format( app, @@ -842,10 +923,16 @@ 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", - "romanHangingPunctuation": false + "direction": "auto", "leadingModel": "romanBaseline", "mojikumi": "lineEndHalf", "burasagari": "standard", "kinsoku": "hard", + "kinsokuType": "pushOutOnly", "composer": "everyLine", "romanHangingPunctuation": false }), ); + para_cmd(app, "text.tabs.clear", json!({})); + // Closes the active document's undo group only, so an undo group open in another document + // stays open. + if !open && let Some(st) = app.session.active_mut() { + st.undo_group = None; + } } #[cfg(test)] diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index a1df5a927..fe66d367f 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -1,4 +1,5 @@ use super::*; +use vectorcraft_doc::Mojikumi; use vectorcraft_doc::NodeId; use vectorcraft_engine::Session; @@ -106,6 +107,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"); @@ -434,6 +461,36 @@ fn the_numbered_list_button_and_its_presets_set_the_list_and_reset_panel_clears_ assert_eq!(lists(&app), (None, None)); } +/// More Options at the end of the Bullets presets opens Bullets and Numbering on its Bullets +/// tab, and at the end of the Numbered List presets on its Numbers tab. +#[test] +fn more_options_opens_bullets_and_numbering_on_its_tab() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let (mut app, _) = editing_text("One\nTwo"); + select_range(&mut app, 0, 0); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let numbers = std::cell::Cell::new(false); + let row = std::cell::Cell::new(egui::Rect::NOTHING); + let mut draw = |app: &mut VectorcraftApp, ui: &mut Ui| { + let r = ui.horizontal(|ui| if numbers.get() { numbers_button(app, ui, 28.0) } else { bullets_button(app, ui, 28.0) }); + row.set(r.response.rect); + }; + for (on_numbers, tab) in [(false, "bullets"), (true, "numbers")] { + numbers.set(on_numbers); + super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], &mut draw); + let arrow = row.get().min + vec2(28.0 + 7.0, 14.0); + super::super::tests_appearance::click(&ctx, &mut app, arrow, &mut draw); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], &mut draw); + let at = super::super::tests_appearance::text_rect(&texts, "More Options").center(); + super::super::tests_appearance::click(&ctx, &mut app, at, &mut draw); + let d = app.ui.dialog.take().unwrap_or_else(|| panic!("More Options opens the dialog from the {tab} presets")); + assert_eq!((d.kind.as_str(), d.str("tab")), ("bulletsAndNumbering", tab.to_string())); + } + app.run("ui.bulletsAndNumbering", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("bulletsAndNumbering")); +} + /// Type › Bullets and Numbering › Convert to Text (`type.convertListToText`) acts on the /// paragraphs the Type tool's selection touches: their numbers become characters of the text /// and their list goes, while the other paragraphs stay in the list, and the Type tool's caret @@ -691,3 +748,143 @@ fn the_control_bar_trims_its_paragraph_section_after_the_controls_to_its_right() assert!(seen.iter().all(|s| *s), "{case}: a wide bar shows every group: {seen:?}"); } } + +/// Reset Panel gives the paragraphs it acts on new type's Mojikumi Set (Line-end Punctuation +/// Half Width) and no tab stops, as one undo step named Reset Panel: while the Type tool edits, +/// the paragraph at its caret (the typing before it stays a step of its own), and then the +/// selected type's paragraphs. In an undo group that is already open, the reset is part of +/// that group's step. +#[test] +fn reset_panel_resets_the_mojikumi_set_and_tab_stops_in_one_step() { + let (mut app, id) = editing_text("First\nSecond"); + app.run("text.setFormat", json!({"id": id, "mojikumi": "none"})).unwrap(); + app.run("text.tabs.set", json!({"id": id, "stops": [{"position": 36}, {"position": 90, "align": "right"}]})).unwrap(); + let text = |app: &VectorcraftApp| { + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + text.as_ref().clone() + }; + let paras = |app: &VectorcraftApp| { + let text = text(app); + [text.para_at(0), text.para_at(1)].map(|p| (p.mojikumi, p.tabs.len())) + }; + let custom = (Mojikumi::None, 2); + let reset = (Mojikumi::LineEndHalf, 0); + assert_eq!(paras(&app), [custom, custom]); + let steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let last_step = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.last().map(|e| e.label.clone()); + // Type over the second paragraph's text: the typing session is still open. + select_range(&mut app, 6, 12); + let before = steps(&app); + let ctx = egui::Context::default(); + let raw = egui::RawInput { events: vec![egui::Event::Text("X".into())], ..Default::default() }; + ctx.run_ui(raw, |ui| crate::shortcuts::handle(&mut app, ui.ctx())).textures_delta.clear(); + assert_eq!(app.session.tool_options()["typing"], json!(true)); + reset_panel(&mut app); + assert_eq!(paras(&app), [custom, reset], "only the paragraph at the caret"); + assert_eq!((steps(&app), last_step(&app)), (before + 2, Some("Reset Panel".to_string()))); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(paras(&app), [custom, custom]); + assert_eq!(text(&app).plain_text(), "First\nX", "the typing stays"); + app.run("edit.redo", json!({})).unwrap(); + app.session.set_tool_option("stopEditing", &json!(true)); + let doc = app.session.active().unwrap().doc.clone(); + let before = steps(&app); + reset_panel(&mut app); + assert_eq!(paras(&app), [reset, reset]); + assert_eq!((steps(&app), last_step(&app)), (before + 1, Some("Reset Panel".to_string()))); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, doc, "one Undo takes the whole reset back"); + // In an undo group that is already open (MCP `command_batch` with `atomic`), the reset is + // part of that group's step. + app.run("edit.undoGroup.begin", json!({"label": "Batch"})).unwrap(); + let before = steps(&app); + reset_panel(&mut app); + assert_eq!(paras(&app), [reset, reset]); + app.run("text.setFormat", json!({"id": id, "mojikumi": "none"})).unwrap(); + app.run("edit.undoGroup.end", json!({})).unwrap(); + assert_eq!((steps(&app), last_step(&app)), (before + 1, Some("Batch".to_string()))); +} + +/// Reset Panel gives paragraphs that start with different Mojikumi Sets and tab stops the same +/// Mojikumi Set and no tab stops, as one undo step, and one Undo gives each paragraph its own +/// values back: while the Type tool edits, the paragraphs the selection touches, and then the +/// paragraphs of every selected type object. +#[test] +fn reset_panel_resets_paragraphs_with_different_values_in_one_step() { + let (mut app, id) = editing_text("First\nSecond\nThird"); + app.run("text.setFormat", json!({"id": id, "start": 0, "end": 1, "mojikumi": "none"})).unwrap(); + app.run("text.setFormat", json!({"id": id, "start": 6, "end": 7, "mojikumi": "lineEndHalf"})).unwrap(); + app.run("text.setFormat", json!({"id": id, "start": 13, "end": 14, "mojikumi": "none"})).unwrap(); + app.run("text.tabs.set", json!({"id": id, "start": 0, "end": 1, "stops": [{"position": 36}, {"position": 90, "align": "right"}]})).unwrap(); + app.run("text.tabs.set", json!({"id": id, "start": 6, "end": 7, "stops": [{"position": 120, "align": "decimal", "leader": "."}]})).unwrap(); + let paras = |app: &VectorcraftApp, id: &Value| { + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + text.paragraph_styles().into_iter().map(|p| (p.mojikumi, p.tabs.iter().map(|t| t.position).collect::>())).collect::>() + }; + let first = (Mojikumi::None, vec![36.0, 90.0]); + let second = (Mojikumi::LineEndHalf, vec![120.0]); + let third = (Mojikumi::None, vec![]); + let reset = (Mojikumi::LineEndHalf, vec![]); + assert_eq!(paras(&app, &id), [first.clone(), second.clone(), third.clone()]); + let steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let last_step = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.last().map(|e| e.label.clone()); + // A selection from the first paragraph into the second. + select_range(&mut app, 2, 8); + let doc = app.session.active().unwrap().doc.clone(); + let before = steps(&app); + reset_panel(&mut app); + assert_eq!(paras(&app, &id), [reset.clone(), reset.clone(), third.clone()], "only the paragraphs the selection touches"); + assert_eq!((steps(&app), last_step(&app)), (before + 1, Some("Reset Panel".to_string()))); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, doc, "one Undo takes the whole reset back"); + assert_eq!(paras(&app, &id), [first.clone(), second.clone(), third.clone()]); + // Two selected type objects. + app.session.set_tool_option("stopEditing", &json!(true)); + let other = app.run("text.create", json!({"x": 40, "y": 120, "text": "Other"})).unwrap()["id"].clone(); + app.run("text.setFormat", json!({"id": other, "mojikumi": "none"})).unwrap(); + app.run("text.tabs.set", json!({"id": other, "stops": [{"position": 48, "align": "center"}]})).unwrap(); + let other_values = (Mojikumi::None, vec![48.0]); + app.run("select.set", json!({"ids": [id, other]})).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + let before = steps(&app); + reset_panel(&mut app); + assert_eq!(paras(&app, &id), [reset.clone(), reset.clone(), reset.clone()]); + assert_eq!(paras(&app, &other), [reset]); + assert_eq!((steps(&app), last_step(&app)), (before + 1, Some("Reset Panel".to_string()))); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, doc, "one Undo takes the whole reset back"); + assert_eq!(paras(&app, &id), [first, second, third]); + assert_eq!(paras(&app, &other), [other_values]); +} + +/// Reset Panel closes only the undo group it opens: an undo group open in another document +/// stays open, and that document's later edits stay part of its step. +#[test] +fn reset_panel_leaves_an_undo_group_open_in_another_document() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "A"})).unwrap()["id"].clone(); + // The second document is open before the group begins, so switching to it opens no group in it. + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + assert_eq!(app.session.active_index(), Some(1)); + app.run("text.create", json!({"x": 20, "y": 50, "text": "B"})).unwrap(); + app.session.set_active(0); + app.run("edit.undoGroup.begin", json!({"label": "Batch"})).unwrap(); + app.run("text.setFormat", json!({"id": a, "mojikumi": "none"})).unwrap(); + let steps = |st: &vectorcraft_engine::DocState| (st.history.undo.len(), st.history.undo.last().map(|e| e.label.clone())); + let a_steps = steps(&app.session.documents()[0]); + assert_eq!(a_steps.1, Some("Batch".to_string())); + app.session.set_active(1); + assert!(app.session.active().unwrap().undo_group.is_none()); + let b_steps = steps(app.session.active().unwrap()).0; + reset_panel(&mut app); + assert_eq!(steps(app.session.active().unwrap()), (b_steps + 1, Some("Reset Panel".to_string()))); + assert!(app.session.active().unwrap().undo_group.is_none()); + assert!(app.session.documents()[0].undo_group.is_some(), "the other document's undo group stays open"); + app.session.set_active(0); + app.run("text.setFormat", json!({"id": a, "mojikumi": "lineEndHalf"})).unwrap(); + app.run("edit.undoGroup.end", json!({})).unwrap(); + assert_eq!(steps(app.session.active().unwrap()), a_steps, "the later edit is part of the Batch step"); +} diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index a1a8c7fb1..57a99e433 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -57,8 +57,11 @@ enum IndentMarker { struct IndentTarget { id: u64, start: usize, + /// The left indent as laid out: the paragraph's own plus its list level's ([`Self::level`]). left: f64, first_line: f64, + /// How much further in the paragraph's list level sets it than its own left indent. + level: f64, } fn append_indent_targets(node: &Node, range: std::ops::Range, targets: &mut Vec) { @@ -68,7 +71,8 @@ fn append_indent_targets(node: &Node, range: std::ops::Range, targets: &m starts.extend(plain.bytes().enumerate().filter_map(|(i, c)| (c == b'\n').then_some(i + 1))); targets.extend(range.filter_map(|i| { let para = text.para_at(i); - Some(IndentTarget { id: node.id.0, start: *starts.get(i)?, left: para.left_indent, first_line: para.first_line_indent }) + let left = para.laid_out_left_indent(); + Some(IndentTarget { id: node.id.0, start: *starts.get(i)?, left, first_line: para.first_line_indent, level: left - para.left_indent }) })); } @@ -113,13 +117,14 @@ fn bounded_marker_position(marker: IndentMarker, target: f64, span: f64, targets IndentMarker::Upper => { (targets.iter().map(|p| p.left - 1296.0).fold(0.0_f64, f64::max), targets.iter().map(|p| p.left + 1296.0).fold(span, f64::min)) } + // A paragraph's own left indent stays within 0 to 1296 pt, its list level's indent added. IndentMarker::Lower if both => ( - targets.iter().map(|p| -p.first_line).fold(0.0_f64, f64::max), - targets.iter().map(|p| span - p.first_line).fold(span.min(1296.0), f64::min), + targets.iter().map(|p| p.level.max(-p.first_line)).fold(0.0_f64, f64::max), + targets.iter().map(|p| (span - p.first_line).min(p.level + 1296.0)).fold(span, f64::min), ), IndentMarker::Lower => ( - targets.iter().map(|p| p.left + p.first_line - 1296.0).fold(0.0_f64, f64::max), - targets.iter().map(|p| p.left + p.first_line + 1296.0).fold(span.min(1296.0), f64::min), + targets.iter().map(|p| p.level.max(p.left + p.first_line - 1296.0)).fold(0.0_f64, f64::max), + targets.iter().map(|p| (p.left + p.first_line + 1296.0).min(p.level + 1296.0)).fold(span, f64::min), ), }; (low <= high).then(|| target.clamp(low, high)) @@ -209,28 +214,34 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: app.status(e); } } - } else if both { - if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", json!({"leftIndent": target})) { - app.status(e); - } - } else if targets.iter().all(|p| p.left + p.first_line == marker_position(IndentMarker::Upper, para)) { - if let Err(e) = super::paragraph::run_on_paragraphs( - app, - "text.setFormat", - json!({"leftIndent": target, "firstLineIndent": marker_position(IndentMarker::Upper, para) - target}), - ) { - app.status(e); - } } else { + // The ruler shows each paragraph's left indent with its list level's added; the paragraph + // keeps its own. + let upper = marker_position(IndentMarker::Upper, para); + let level = targets.first().map(|p| p.level).filter(|l| targets.iter().all(|p| p.level == *l)); + let params = match level { + Some(level) if both => Some(json!({"leftIndent": target - level})), + Some(level) if targets.iter().all(|p| p.left + p.first_line == upper) => { + Some(json!({"leftIndent": target - level, "firstLineIndent": upper - target})) + } + _ => None, + }; + if let Some(params) = params { + if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", params) { + app.status(e); + } + return; + } super::character::end_typing(app); let commands: Vec = targets .iter() .rev() .map(|p| { - json!({"command": "text.setFormat", "params": { - "ids": [p.id], "start": p.start, "end": p.start, - "leftIndent": target, "firstLineIndent": p.left + p.first_line - target - }}) + let mut params = json!({"ids": [p.id], "start": p.start, "end": p.start, "leftIndent": target - p.level}); + if !both { + params["firstLineIndent"] = json!(p.left + p.first_line - target); + } + json!({"command": "text.setFormat", "params": params}) }) .collect(); if let Err(e) = app.run("command.batch", json!({"label": tl!("Left Indent"), "commands": commands})) { @@ -245,7 +256,9 @@ fn current(app: &VectorcraftApp) -> Option<(Node, Vec, f64)> { current_para(app).map(|(n, para, span)| (n, para.tabs, span)) } -/// As [`current`], with all the attributes of the paragraph whose stops the panel shows. +/// As [`current`], with all the attributes of the paragraph whose stops the panel shows, its left +/// indent as laid out ([`ParaStyle::laid_out_left_indent`]: its list level's indent added), where +/// the ruler shows it. fn current_para(app: &VectorcraftApp) -> Option<(Node, ParaStyle, f64)> { let editing = super::character::text_editing(app); let n = match editing { @@ -258,7 +271,8 @@ fn current_para(app: &VectorcraftApp) -> Option<(Node, ParaStyle, f64)> { TextKind::Area { frame } => frame.bounds().map_or(360.0, |b| b.width()), _ => 360.0, }; - let para = t.para_at(para).clone(); + let para = t.para_at(para); + let para = ParaStyle { left_indent: para.laid_out_left_indent(), ..para.clone() }; Some((n, para, span.max(72.0))) } @@ -638,6 +652,8 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { snap_to_unit_item(ui); return; }; + // `current_para` gives the left indent with the list level's indent added. + let left_indent = para.left_indent; let mut stops = para.tabs; let selected_marker: Option = pstate(ui.ctx(), "tabs-marker-sel"); // Clamp the selection to the last stop, as `show` does for the ruler. @@ -655,10 +671,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { apply(app, &stops); } // Repeat Tab: copies of the selected stop across the ruler, spaced by its distance from the - // previous stop, or from the paragraph's left indent when it is the first stop. + // previous stop, or from the paragraph's left indent plus its list level's indent when it is + // the first stop. if menu_item(ui, tl!("Repeat Tab"), sel < stops.len() && selected_marker.is_none(), false) { let step = match sel { - 0 => (stops[0].position - para.left_indent).abs(), + 0 => (stops[0].position - left_indent).abs(), _ => stops[sel].position - stops[sel - 1].position, } .max(1.0); diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index 3244ba243..bc71e3408 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -540,9 +540,32 @@ fn indent_markers_after_select_all_follow_text_that_reflows_into_the_next_frame( #[test] fn lower_marker_bounds_cover_each_touched_first_line() { - let targets = [IndentTarget { id: 1, start: 0, left: 24.0, first_line: -20.0 }, IndentTarget { id: 1, start: 4, left: 24.0, first_line: 280.0 }]; + let targets = [ + IndentTarget { id: 1, start: 0, left: 24.0, first_line: -20.0, level: 0.0 }, + IndentTarget { id: 1, start: 4, left: 24.0, first_line: 280.0, level: 0.0 }, + ]; assert_eq!(bounded_marker_position(IndentMarker::Lower, 40.0, 300.0, &targets, true), Some(20.0)); assert_eq!(bounded_marker_position(IndentMarker::Lower, 1200.0, 300.0, &targets, false), Some(300.0)); + // A paragraph at list level 2 is laid out 18 pt further in: its own left indent stays at 0 or more. + let listed = [IndentTarget { id: 1, start: 0, left: 18.0, first_line: 0.0, level: 18.0 }]; + assert_eq!(bounded_marker_position(IndentMarker::Lower, 5.0, 300.0, &listed, true), Some(18.0)); +} + +/// A list paragraph at level 2 is laid out 18 pt further in than its left indent: the ruler shows +/// its markers there, and a marker moved on the ruler sets the paragraph's own left indent. +#[test] +fn indent_markers_show_and_set_a_list_paragraphs_laid_out_indent() { + let mut app = indent_app(10.0, 6.0); + app.run("text.setFormat", json!({"list": "bullet", "listLevel": 2})).unwrap(); + let para = current_para(&app).unwrap().1; + assert_eq!((marker_position(IndentMarker::Lower, ¶), marker_position(IndentMarker::Upper, ¶)), (28.0, 34.0)); + let ctx = egui::Context::default(); + marker_event(&ctx, &mut app, 28.0, 40.0, false, true, false); + assert_eq!(indent_values(&app, 0), (22.0, 6.0), "the ruler's 40 pt is the paragraph's 22 pt and the level's 18"); + marker_event(&ctx, &mut app, 46.0, 70.0, true, false, false); + assert_eq!(indent_values(&app, 0), (22.0, 30.0)); + marker_event(&ctx, &mut app, 40.0, 20.0, false, false, false); + assert_eq!(indent_values(&app, 0), (2.0, 50.0), "the first line stays at 70 pt"); } #[test] @@ -1100,7 +1123,8 @@ fn panel_menu_does_not_change_stops_while_an_indent_marker_is_selected() { } /// Repeat Tab on the first stop spaces the copies by the stop's distance from the paragraph's -/// left indent, from the paragraph the caret is in while the Type tool edits text. +/// left indent (18 pt further in for each list level past the first), from the paragraph the +/// caret is in while the Type tool edits text. #[test] fn repeat_tab_measures_the_first_stop_from_the_left_indent() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -1119,6 +1143,11 @@ fn repeat_tab_measures_the_first_stop_from_the_left_indent() { app.session.execute("text.tabs.set", &json!({"stops": [{"position": 5}]})).unwrap(); choose(&ctx, &mut app, "Repeat Tab"); assert_eq!(positions(&app)[..4], [5.0, 20.0, 35.0, 50.0]); + // A list paragraph at level 2 is set 18 pt further in, at 38: copies every 12 pt. + app.session.execute("text.setFormat", &json!({"list": "bullet", "listLevel": 2})).unwrap(); + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}]})).unwrap(); + choose(&ctx, &mut app, "Repeat Tab"); + assert_eq!(positions(&app)[..3], [50.0, 62.0, 74.0]); // The Type tool's caret in the second paragraph, indented 40 pt: its stop at 50 repeats // every 10 pt, and the first paragraph keeps its stop. let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/panels/tests_appearance.rs b/crates/ui-egui/src/panels/tests_appearance.rs index ba4eebdda..0431d6dec 100644 --- a/crates/ui-egui/src/panels/tests_appearance.rs +++ b/crates/ui-egui/src/panels/tests_appearance.rs @@ -1348,10 +1348,15 @@ fn app_click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, f: } } -/// `key` pressed and released in an app-like frame of `f`, then one more frame. -fn app_key(ctx: &egui::Context, app: &mut VectorcraftApp, key: egui::Key, f: fn(&mut VectorcraftApp, &mut egui::Ui)) { +/// `key` pressed and released in one app-like frame of `f`, with no idle frame after it. +fn app_key_now(ctx: &egui::Context, app: &mut VectorcraftApp, key: egui::Key, f: fn(&mut VectorcraftApp, &mut egui::Ui)) { let ev = |pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE }; app_frame(ctx, app, vec![ev(true), ev(false)], f); +} + +/// `key` pressed and released in an app-like frame of `f`, then one more frame. +fn app_key(ctx: &egui::Context, app: &mut VectorcraftApp, key: egui::Key, f: fn(&mut VectorcraftApp, &mut egui::Ui)) { + app_key_now(ctx, app, key, f); app_frame(ctx, app, vec![], f); } @@ -1481,6 +1486,53 @@ fn after_a_row_click_delete_reaches_the_layers_panel_not_edit_clear() { assert!(d.node(rect).is_some(), "the rectangle on Layer 1 stays"); } +/// After Delete removed the last highlighted Layers row, the panel has nothing left to delete: the +/// next Delete must go back to the app and clear the selected object, not be swallowed by the +/// panel's row keys. +#[test] +fn delete_after_the_last_highlighted_layer_row_is_deleted_clears_the_selection() { + let ctx = drop_ctx(); + let mut app = app_with_rect(); + run(&mut app, "layer.new", json!({})); + let rect = first_selected(&app).unwrap().id; + let texts = frame_events(&ctx, &mut app, vec![], boxed_layers); + let row = text_rect(&texts, "Layer 2").center(); + app_click(&ctx, &mut app, row, boxed_layers); + app_key_now(&ctx, &mut app, egui::Key::Delete, boxed_layers); + assert_eq!(app.session.active().unwrap().doc.layers.len(), 1, "Layer 2 is deleted"); + assert!(app.session.active().unwrap().selection.contains(rect), "the rectangle stays selected"); + app_key_now(&ctx, &mut app, egui::Key::Delete, boxed_layers); + assert!(app.session.active().unwrap().doc.node(rect).is_none(), "Delete clears the selected object once the panel has no rows"); +} + +/// Clicking a row right after the last highlighted row was deleted restores the row keys: Delete +/// then deletes that row, not the selected art. +#[test] +fn clicking_a_row_after_the_last_one_was_deleted_keeps_delete_with_the_layers_panel() { + let ctx = drop_ctx(); + let mut app = app_with_rect(); + run(&mut app, "layer.new", json!({})); + run(&mut app, "layer.new", json!({})); + let rect = first_selected(&app).unwrap().id; + let texts = frame_events(&ctx, &mut app, vec![], boxed_layers); + let layer2 = text_rect(&texts, "Layer 2").center(); + app_click(&ctx, &mut app, layer2, boxed_layers); + app_key_now(&ctx, &mut app, egui::Key::Delete, boxed_layers); + assert_eq!(app.session.active().unwrap().doc.layers.len(), 2, "Layer 2 is deleted"); + // Click Layer 3 and press Delete in the very next frame: the click's highlight action runs at + // the end of its frame, so the panel must read the rows the click will leave. + let texts = frame_events(&ctx, &mut app, vec![], boxed_layers); + let layer3 = text_rect(&texts, "Layer 3").center(); + let button = + |pressed| egui::Event::PointerButton { pos: layer3, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + for e in [egui::Event::PointerMoved(layer3), button(true), button(false)] { + app_frame(&ctx, &mut app, vec![e], boxed_layers); + } + app_key_now(&ctx, &mut app, egui::Key::Delete, boxed_layers); + assert_eq!(app.session.active().unwrap().doc.layers.len(), 1, "Delete removes the clicked row"); + assert!(app.session.active().unwrap().doc.node(rect).is_some(), "the rectangle on Layer 1 stays"); +} + /// A tool that claims Delete and Backspace (the Artboard tool's active artboard) leaves them to a /// clicked Layers row. #[test] diff --git a/crates/ui-egui/src/panels/tests_asset_export.rs b/crates/ui-egui/src/panels/tests_asset_export.rs index 3c686d335..82a6f2124 100644 --- a/crates/ui-egui/src/panels/tests_asset_export.rs +++ b/crates/ui-egui/src/panels/tests_asset_export.rs @@ -598,6 +598,29 @@ fn tiff_photometric(bytes: &[u8]) -> u16 { short(entry + 8) } +/// Remembered format settings that hold keys the export no longer reads, and settings for a format +/// it doesn't write, are left out of the panel's export, so it is not refused. +#[test] +fn export_leaves_out_remembered_keys_no_longer_read() { + let (mut app, log, [a, _]) = app(true); + app.run("assets.add", json!({"ids": [a], "name": "Badge"})).unwrap(); + let serde_json::Value::Object(saved) = json!({ + "formats": [{"format": "tiff", "scale": "1x", "noSuchOption": 1}], + "settings": {"png": {"noSuchSetting": true}, "dxf": {}, "tiff": {"lzw": false, "noSuchSetting": 1}} + }) else { + panic!("an object") + }; + std::sync::Arc::make_mut(&mut app.session.active_mut().unwrap().doc).export_settings = saved; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, &mut app, vec![], false); + let texts = frame(&ctx, &mut app, vec![], false); + frame(&ctx, &mut app, click(at(&texts, "Export")), false); + let log = log.borrow(); + let [(name, _)] = log.as_slice() else { panic!("expected one TIFF: {}", app.ui.status) }; + assert_eq!(name, "Badge.tif"); +} + #[test] fn tiff_cmyk_defaults_match_screens_and_preserve_explicit_rgb() { for rgb_override in [false, true] { @@ -743,3 +766,78 @@ fn tiff_saved_aliases_use_document_defaults_and_explicit_rgb() { } let _ = std::fs::remove_dir_all(&dir); } + +/// Export 3D Object in the panel menu collects the selected 3D object; its tile is marked 3D, the +/// Format row sets its mesh format, and Export writes it as a mesh file beside the pictures. +#[test] +fn a_3d_asset_takes_its_format_from_the_row_and_exports_as_a_mesh() { + let (mut app, log, [a, b]) = app(true); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "params": {"depth": 20}})).unwrap(); + app.run("layer.setProps", json!({"id": a, "name": "Box"})).unwrap(); + app.run("assets.add", json!({"ids": [b], "name": "Flat"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + // Without a 3D asset there is no 3D Format row. + frame(&ctx, &mut app, vec![], false); + assert!(!has(&frame(&ctx, &mut app, vec![], false), "3D Format:")); + menu_frame(&ctx, &mut app, vec![]); + let items = menu_frame(&ctx, &mut app, vec![]); + menu_frame(&ctx, &mut app, click(at(&items, " Export 3D Object"))); + let list = assets(&mut app); + assert_eq!((list[1]["name"].clone(), list[1]["format3D"].clone()), (json!("Box"), json!("usda"))); + let texts = frame(&ctx, &mut app, vec![], false); + let texts = if has(&texts, "3D Format:") { texts } else { frame(&ctx, &mut app, vec![], false) }; + assert!(has(&texts, "3D") && has(&texts, "3D Format:"), "{:?}", texts.iter().map(|t| &t.0).collect::>()); + frame(&ctx, &mut app, click(at(&texts, "USDA")), false); + let texts = frame(&ctx, &mut app, vec![], false); + frame(&ctx, &mut app, click(at(&texts, "OBJ")), false); + assert_eq!(assets(&mut app)[1]["format3D"], "obj"); + // The menu left the new 3D asset selected: Export writes its OBJ and MTL alone, as one zip. + let texts = frame(&ctx, &mut app, vec![], false); + frame(&ctx, &mut app, click(at(&texts, "Export 1 Selected")), false); + { + let log = log.borrow(); + let [(name, zip)] = log.as_slice() else { panic!("{:?} {}", log.iter().map(|f| &f.0).collect::>(), app.ui.status) }; + assert!(name.ends_with(".zip"), "{name}"); + let has_name = |n: &str| zip.windows(n.len()).any(|w| w == n.as_bytes()); + assert!(has_name("Box.obj") && has_name("Box.mtl")); + } + // Every asset: the 3D asset's files first, since they are written at once, then the picture + // asset as before. + log.borrow_mut().clear(); + menu_frame(&ctx, &mut app, vec![]); + let items = menu_frame(&ctx, &mut app, vec![]); + menu_frame(&ctx, &mut app, click(at(&items, " Select All Assets"))); + frame(&ctx, &mut app, vec![], false); + let texts = frame(&ctx, &mut app, vec![], false); + frame(&ctx, &mut app, click(at(&texts, "Export")), false); + let log = log.borrow(); + assert_eq!(log.iter().map(|f| f.0.ends_with(".zip")).collect::>(), [true, false], "{:?}", log.iter().map(|f| &f.0).collect::>()); + assert!(log[1].0.starts_with("Flat"), "{}", log[1].0); +} + +/// When the 3D assets fail to export, Export leaves the picture assets unexported and the status +/// bar keeps the 3D error. +#[test] +fn a_failed_3d_export_stops_the_picture_export() { + let (mut app, log, [a, b]) = app(true); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "params": {"depth": 20}})).unwrap(); + app.run("layer.setProps", json!({"id": a, "name": "Box"})).unwrap(); + app.run("assets.add3D", json!({"ids": [a]})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.run("effect.remove", json!({"index": 0})).unwrap(); + app.run("assets.add", json!({"ids": [b], "name": "Flat"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, &mut app, vec![], false); + menu_frame(&ctx, &mut app, vec![]); + let items = menu_frame(&ctx, &mut app, vec![]); + menu_frame(&ctx, &mut app, click(at(&items, " Select All Assets"))); + frame(&ctx, &mut app, vec![], false); + let texts = frame(&ctx, &mut app, vec![], false); + frame(&ctx, &mut app, click(at(&texts, "Export")), false); + assert!(log.borrow().is_empty(), "{:?}", log.borrow().iter().map(|f| &f.0).collect::>()); + assert!(app.ui.status.contains("Box"), "{}", app.ui.status); +} diff --git a/crates/ui-egui/src/panels/tests_color_wheel.rs b/crates/ui-egui/src/panels/tests_color_wheel.rs new file mode 100644 index 000000000..798a842e4 --- /dev/null +++ b/crates/ui-egui/src/panels/tests_color_wheel.rs @@ -0,0 +1,583 @@ +//! The Color Wheel panel drawn headless: Window › Color Wheel, picks on the hue ring and the +//! square, Shift and Alt, gradient stops, None, the remembered hue, the color model of a pick, the +//! arrow keys and what screen readers read. + +use egui::accesskit::{Action, ActionData, ActionRequest}; +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; +use vectorcraft_color::cms::{self, Cms, ColorSettings, delta_e2000}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_engine::Session; + +use super::color_wheel::{ID, display_hsb, remembered_hsb, show, stored}; +use crate::VectorcraftApp; + +/// An app with a selected rectangle filled with `fill`. +fn app(fill: serde_json::Value) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app.run("paint.setFill", json!({"color": fill})).unwrap(); + app +} + +/// The panel in a 260-point-wide flyout, with the app's shortcuts after it as in a frame of the app. +fn harness(app: VectorcraftApp) -> Harness<'static, VectorcraftApp> { + let mut h = Harness::builder().with_size(vec2(260.0, 320.0)).build_ui_state( + |ui, app: &mut VectorcraftApp| { + show(app, ui); + crate::shortcuts::handle(app, ui.ctx()); + }, + app, + ); + h.run_steps(2); + h +} + +fn rect_of(h: &Harness<'static, VectorcraftApp>, label: &str) -> Rect { + h.get_by_label(label).rect() +} + +/// The point at hue `deg` on the ring drawn in `ring`, `out` points outside the middle of its band. +fn on_ring(ring: Rect, deg: f32, out: f32) -> Pos2 { + let r = ring.width() / 2.0 - 4.0 - 7.0 + out; + let a = deg.to_radians(); + ring.center() + vec2(a.cos(), -a.sin()) * r +} + +/// The point (x across, y up; 0..1 each) of the square. +fn in_square(sq: Rect, x: f32, y: f32) -> Pos2 { + Pos2::new(sq.left() + x * sq.width(), sq.bottom() - y * sq.height()) +} + +fn press(h: &mut Harness<'static, VectorcraftApp>, pos: Pos2, pressed: bool, modifiers: Modifiers) { + h.event(Event::ModifiersChanged(modifiers)); + h.event(Event::PointerMoved(pos)); + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }); + h.run_steps(1); +} + +fn click(h: &mut Harness<'static, VectorcraftApp>, pos: Pos2, modifiers: Modifiers) { + press(h, pos, true, modifiers); + press(h, pos, false, modifiers); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(1); +} + +/// A click whose press and release come in one frame (`Harness::event` gives each event a frame of +/// its own). +fn quick_click(h: &mut Harness<'static, VectorcraftApp>, pos: Pos2) { + h.event(Event::PointerMoved(pos)); + h.run_steps(1); + let button = |pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.input_mut().events.extend([button(true), button(false)]); + h.run_steps(2); +} + +/// A drag through `points`, pressed at the first and released at the last. +fn drag(h: &mut Harness<'static, VectorcraftApp>, points: &[Pos2], modifiers: Modifiers) { + let (Some(first), Some(last)) = (points.first(), points.last()) else { return }; + press(h, *first, true, modifiers); + for p in points { + for _ in 0..2 { + h.event(Event::PointerMoved(*p)); + h.run_steps(1); + } + } + press(h, *last, false, modifiers); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(1); +} + +fn key(h: &mut Harness<'static, VectorcraftApp>, key: egui::Key, modifiers: Modifiers) { + h.key_press_modifiers(modifiers, key); + h.run_steps(1); +} + +fn fill(app: &VectorcraftApp) -> Color { + crate::panels::current_paints(app).0.color().expect("a solid fill") +} + +fn hsb(c: &Color) -> [f32; 3] { + display_hsb(&cms::active(), c) +} + +fn near(a: [f32; 3], b: [f32; 3], tol: [f32; 3]) -> bool { + (0..3).all(|i| (a[i] - b[i]).abs() <= tol[i]) +} + +fn undo_len(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().history.undo.len() +} + +/// Window › Color Wheel opens the panel, and a click on the left of the ring turns a red fill cyan +/// at the same saturation and brightness. +#[test] +fn window_color_wheel_opens_the_panel_and_the_ring_picks_the_hue() { + let mut app = app(json!("#ff0000")); + app.run("window.panel", json!({"panel": ID})).unwrap(); + assert!(crate::panel_docking::showing(&app.ui, ID)); + assert!(crate::state::ICON_PANELS.iter().any(|(id, label, _)| *id == ID && *label == "Color Wheel")); + let item = crate::menus::menu_entries(&app).into_iter().find(|e| e.path == ["Window"] && e.label == "Color Wheel"); + assert!(item.is_some(), "the Window menu lists it"); + let mut h = harness(app); + let ring = rect_of(&h, "Hue ring"); + let undo = undo_len(h.state()); + click(&mut h, on_ring(ring, 180.0, 0.0), Modifiers::NONE); + assert_eq!(fill(h.state()).to_hex(), "#00ffff"); + assert_eq!(undo_len(h.state()), undo + 1, "one undo step"); +} + +#[test] +fn the_square_picks_saturation_across_and_brightness_up() { + let mut h = harness(app(json!([0.6, 0.3, 0.3]))); + let sq = rect_of(&h, "Saturation and brightness"); + click(&mut h, sq.right_top() + vec2(-0.5, 0.5), Modifiers::NONE); + assert!(near(hsb(&fill(h.state())), [0.0, 1.0, 1.0], [0.5, 0.01, 0.01]), "{:?}", hsb(&fill(h.state()))); + click(&mut h, in_square(sq, 0.25, 0.5), Modifiers::NONE); + assert!(near(hsb(&fill(h.state())), [0.0, 0.25, 0.5], [0.5, 0.01, 0.01]), "{:?}", hsb(&fill(h.state()))); +} + +/// A press between the square and the ring's inner edge picks the closest point of the square. +#[test] +fn a_press_inside_the_ring_beside_the_square_picks_its_closest_point() { + let mut h = harness(app(json!([0.6, 0.3, 0.3]))); + let sq = rect_of(&h, "Saturation and brightness"); + // 1.4 points beyond the corner along the diagonal: the band's inner edge is 2 points out. + click(&mut h, sq.right_top() + vec2(1.0, -1.0), Modifiers::NONE); + assert!(near(hsb(&fill(h.state())), [0.0, 1.0, 1.0], [0.5, 1e-4, 1e-4]), "{:?}", hsb(&fill(h.state()))); +} + +/// The part a press starts on keeps the drag: from the ring into the square only the hue changes. +#[test] +fn a_drag_from_the_ring_into_the_square_changes_only_the_hue() { + let mut h = harness(app(json!([0.6, 0.3, 0.3]))); + let (ring, sq) = (rect_of(&h, "Hue ring"), rect_of(&h, "Saturation and brightness")); + let before = hsb(&fill(h.state())); + drag(&mut h, &[on_ring(ring, 90.0, 0.0), sq.center() + vec2(-10.0, 0.0)], Modifiers::NONE); + let after = hsb(&fill(h.state())); + assert!(near(after, [180.0, before[1], before[2]], [0.5, 1e-3, 1e-3]), "{before:?} → {after:?}"); +} + +/// A press up to 4 points outside the band still picks a hue; one in the rect's corner does nothing. +#[test] +fn a_press_just_outside_the_ring_picks_a_hue_and_the_corners_do_nothing() { + let mut h = harness(app(json!("#ff0000"))); + let ring = rect_of(&h, "Hue ring"); + click(&mut h, on_ring(ring, 180.0, 7.0 + 3.0), Modifiers::NONE); + assert_eq!(fill(h.state()).to_hex(), "#00ffff"); + click(&mut h, ring.left_top() + vec2(2.0, 2.0), Modifiers::NONE); + assert_eq!(fill(h.state()).to_hex(), "#00ffff"); +} + +/// Each click takes the part of the ring under it, also when its press and release come in one +/// frame, and a click on the band gives the ring the keyboard focus. +#[test] +fn a_click_in_one_frame_takes_the_part_under_it() { + let mut h = harness(app(json!([0.6, 0.3, 0.3]))); + let (ring, sq) = (rect_of(&h, "Hue ring"), rect_of(&h, "Saturation and brightness")); + click(&mut h, ring.left_top() + vec2(2.0, 2.0), Modifiers::NONE); + quick_click(&mut h, on_ring(ring, 120.0, 0.0)); + assert!(near(hsb(&fill(h.state())), [120.0, 0.5, 0.6], [0.5, 1e-3, 1e-3]), "after a corner: {:?}", hsb(&fill(h.state()))); + key(&mut h, egui::Key::ArrowRight, Modifiers::NONE); + assert!(near(hsb(&fill(h.state())), [121.0, 0.5, 0.6], [0.5, 1e-3, 1e-3]), "focused: {:?}", hsb(&fill(h.state()))); + quick_click(&mut h, sq.right_top() + vec2(1.0, -1.0)); + assert!(near(hsb(&fill(h.state())), [121.0, 1.0, 1.0], [0.5, 1e-3, 1e-3]), "after the band: {:?}", hsb(&fill(h.state()))); +} + +#[test] +fn shift_drag_on_the_ring_steps_the_hue_by_45_degrees() { + let mut h = harness(app(json!("#ff0000"))); + let ring = rect_of(&h, "Hue ring"); + drag(&mut h, &[on_ring(ring, 10.0, 0.0), on_ring(ring, 60.0, 0.0), on_ring(ring, 100.0, 0.0)], Modifiers::SHIFT); + assert!(near(hsb(&fill(h.state())), [90.0, 1.0, 1.0], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); +} + +#[test] +fn alt_click_paints_the_stroke_and_the_fill_stays_in_front() { + let mut app = app(json!("#ff0000")); + app.run("paint.setStroke", json!({"color": "#808080", "focus": false})).unwrap(); + assert!(app.session.fill_active); + let mut h = harness(app); + let ring = rect_of(&h, "Hue ring"); + click(&mut h, on_ring(ring, 120.0, 0.0), Modifiers::ALT); + let (f, s) = crate::panels::current_paints(h.state()); + assert_eq!((f.color().unwrap().to_hex(), s.color().unwrap().to_hex()), ("#ff0000".into(), "#00ff00".into())); + assert!(h.state().session.fill_active, "the fill stays in front"); +} + +#[test] +fn a_pick_recolors_the_selected_gradient_stop() { + let mut app = app(json!("#ff0000")); + let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]); + app.run("paint.setFill", json!({"gradient": {"stops": stops, "start": [10, 35], "end": [60, 35]}})).unwrap(); + app.select_tool("gradient"); + app.run("gradient.selectStop", json!({"index": 1})).unwrap(); + let mut h = harness(app); + let ring = rect_of(&h, "Hue ring"); + click(&mut h, on_ring(ring, 120.0, 0.0), Modifiers::NONE); + let Paint::Gradient(g) = crate::panels::current_paints(h.state()).0 else { panic!("still a gradient") }; + let hex: Vec = g.gradient.stops.iter().map(|s| s.color.to_hex()).collect(); + assert_eq!(hex, ["#ff0000", "#00ff00"]); +} + +/// With None there is no color to edit: no markers, and a click sets the fill. +#[test] +fn with_a_none_fill_the_wheel_has_no_marker_and_a_click_sets_the_fill() { + let mut app = app(json!("#ff0000")); + app.run("paint.setFill", json!({"none": true})).unwrap(); + let mut h = harness(app); + assert_eq!(h.get_by_label("Saturation and brightness").value(), None, "no marker"); + let sq = rect_of(&h, "Saturation and brightness"); + click(&mut h, in_square(sq, 0.5, 0.5), Modifiers::NONE); + let c = fill(h.state()); + assert!(near(hsb(&c), [hsb(&h.state().session.last_solid)[0], 0.5, 0.5], [0.5, 0.01, 0.01]), "{:?}", hsb(&c)); +} + +/// With None the ring and the square still take a screen reader's actions and the arrow keys. They +/// start from the last solid color the wheel shows, and the result sets the fill. +#[test] +fn with_a_none_fill_screen_readers_and_the_arrow_keys_set_the_fill() { + let mut app = app(json!("#ff0000")); + app.run("paint.setFill", json!({"none": true})).unwrap(); + let mut h = harness(app); + let last = hsb(&h.state().session.last_solid); + let (target_node, target_tree) = h.get_by_label("Hue ring").accesskit_node().locate(); + let data = Some(ActionData::NumericValue(200.0)); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::SetValue, data })); + h.run_steps(1); + assert!(near(hsb(&fill(h.state())), [200.0, last[1], last[2]], [0.5, 2e-3, 2e-3]), "{:?}", hsb(&fill(h.state()))); + // A click focuses the square; with the fill set to None again the arrows still step it. + let sq = rect_of(&h, "Saturation and brightness"); + click(&mut h, in_square(sq, 0.5, 0.5), Modifiers::NONE); + h.state_mut().run("paint.setFill", json!({"none": true})).unwrap(); + h.run_steps(1); + assert_eq!(h.get_by_label("Saturation and brightness").value(), None, "no marker"); + let last = hsb(&h.state().session.last_solid); + key(&mut h, egui::Key::ArrowLeft, Modifiers::NONE); + key(&mut h, egui::Key::ArrowDown, Modifiers::NONE); + let after = hsb(&fill(h.state())); + assert!(near(after, [last[0], last[1] - 0.01, last[2] - 0.01], [0.5, 2e-3, 2e-3]), "{last:?} → {after:?}"); +} + +/// The hue stays while the square passes through a gray: a drag to the left edge (white, whose own +/// hue is 0°) and back ends at the color a click at its end gives, for an RGB and for a CMYK fill +/// (whose stored color differs from the picked RGB), and an RGB green is green again. +#[test] +fn a_gray_keeps_the_hue_it_was_dragged_from() { + for fill_json in [json!("#00ff00"), json!({"c": 0.8, "m": 0.1, "y": 0.9, "k": 0.0})] { + let mut h = harness(app(fill_json.clone())); + let before = hsb(&fill(h.state())); + let sq = rect_of(&h, "Saturation and brightness"); + // Inside the square's edges; the drag past its left edge picks saturation 0. + let at = |x: f32| in_square(sq, x.clamp(0.005, 0.995), before[2].clamp(0.005, 0.995)); + let gray = Pos2::new(sq.left() - 5.0, at(0.0).y); + drag(&mut h, &[at(before[1]), gray, at(before[1])], Modifiers::NONE); + let dragged = fill(h.state()); + let mut direct = harness(app(fill_json.clone())); + click(&mut direct, at(before[1]), Modifiers::NONE); + assert_eq!(dragged.to_hex(), fill(direct.state()).to_hex(), "{fill_json}: {before:?} → {:?}", hsb(&dragged)); + assert!((hsb(&dragged)[0] - before[0]).abs() < 5.0, "{fill_json}: {before:?} → {:?}", hsb(&dragged)); + } + let mut h = harness(app(json!("#00ff00"))); + let sq = rect_of(&h, "Saturation and brightness"); + drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() + 1.0), in_square(sq, 0.995, 0.995)], Modifiers::NONE); + assert!(near(hsb(&fill(h.state())), [120.0, 1.0, 1.0], [0.5, 0.01, 0.01]), "{:?}", hsb(&fill(h.state()))); +} + +/// The remembered hue belongs to the object it was picked on: another object of the same color +/// shows its own hue. +#[test] +fn the_remembered_hue_stays_with_its_object() { + let mut app = app(json!("#00ff00")); + let a = app.session.active().unwrap().selection.objects[0].0; + let b = app.run("shape.rectangle", json!({"x": 100, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"color": "#ffffff", "ids": [b]})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + let mut h = harness(app); + let sq = rect_of(&h, "Saturation and brightness"); + // Green to white (saturation 0 at the top edge): the wheel keeps hue 120°. + drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() - 3.0)], Modifiers::NONE); + assert_eq!(fill(h.state()), Color::WHITE); + h.state_mut().run("select.set", json!({"ids": [b]})).unwrap(); + h.run_steps(1); + click(&mut h, in_square(sq, 0.995, 0.995), Modifiers::NONE); + let hue = hsb(&fill(h.state()))[0]; + assert!(!(1.0..359.0).contains(&hue), "the white object's own hue is 0°, not 120°: {hue}"); +} + +/// Equal colors on two appearance fills have separate remembered hues. A square pick on the +/// second white fill uses its own 0° hue after the first fill was changed from green to white. +#[test] +fn the_remembered_hue_stays_with_its_appearance_item() { + let mut app = app(json!("#00ff00")); + let id = app.session.active().unwrap().selection.objects[0]; + app.run("appearance.addFill", json!({})).unwrap(); // [Fill, Stroke, Fill] + app.run("paint.setFill", json!({"item": 2, "color": "#ffffff"})).unwrap(); + app.run("appearance.setActiveItem", json!({"index": 0})).unwrap(); + let mut h = harness(app); + let sq = rect_of(&h, "Saturation and brightness"); + drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() - 3.0)], Modifiers::NONE); + let item_fill = |app: &VectorcraftApp, index| { + app.session.doc().unwrap().doc.node(id).unwrap().appearance.fill_at(Some(index)).unwrap().paint.color().unwrap() + }; + assert_eq!(item_fill(h.state(), 0), Color::WHITE); + assert_eq!(item_fill(h.state(), 2), Color::WHITE); + h.state_mut().run("appearance.setActiveItem", json!({"index": 2})).unwrap(); + h.run_steps(1); + click(&mut h, in_square(sq, 0.995, 0.995), Modifiers::NONE); + let hue = hsb(&item_fill(h.state(), 2))[0]; + assert!(!(1.0..359.0).contains(&hue), "the second fill's own hue is 0°, not 120°: {hue}"); + assert_eq!(item_fill(h.state(), 0), Color::WHITE); +} + +/// The Type tool can recolor a selected character range without changing the selected object. +/// The next range of the same color starts with its own hue. +#[test] +fn the_remembered_hue_stays_with_its_text_selection() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 10, "y": 100, "text": "AB", "color": "#00ff00"})).unwrap()["id"].as_u64().unwrap(); + app.run("text.setRangeStyle", json!({"id": id, "start": 1, "end": 2, "fill": "#ffffff"})).unwrap(); + app.select_tool("type"); + app.session.set_tool_option("textSelection", &json!({"id": id, "start": 0, "end": 1})); + let mut h = harness(app); + let sq = rect_of(&h, "Saturation and brightness"); + drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() - 3.0)], Modifiers::NONE); + h.state_mut().session.set_tool_option("textSelection", &json!({"id": id, "start": 1, "end": 2})); + h.run_steps(1); + click(&mut h, in_square(sq, 0.995, 0.995), Modifiers::NONE); + let node = h.state().session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(); + let vectorcraft_doc::NodeKind::Text(t) = &node.kind else { panic!("still text") }; + assert_eq!(t.runs.first().unwrap().style.fill.color(), Some(Color::WHITE)); + let hue = hsb(&t.runs.last().unwrap().style.fill.color().unwrap())[0]; + assert!(!(1.0..359.0).contains(&hue), "the second character's own hue is 0°, not 120°: {hue}"); +} + +/// Replacing a gradient can keep the same selected stop and color while changing the paint it +/// belongs to. The new gradient starts with that stop's own hue. +#[test] +fn the_remembered_hue_stays_with_its_gradient() { + let mut app = app(json!("#00ff00")); + let first = json!([{"offset": 0, "color": "#00ff00"}, {"offset": 1, "color": "#0000ff"}]); + app.run("paint.setFill", json!({"gradient": {"stops": first}})).unwrap(); + app.run("gradient.selectStop", json!({"index": 0})).unwrap(); + let mut h = harness(app); + let sq = rect_of(&h, "Saturation and brightness"); + drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() - 3.0)], Modifiers::NONE); + let second = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#ff0000"}]); + h.state_mut().run("paint.setFill", json!({"gradient": {"stops": second}})).unwrap(); + assert_eq!(h.state().session.selected_stop(), Some(0)); + h.run_steps(1); + click(&mut h, in_square(sq, 0.995, 0.995), Modifiers::NONE); + let Paint::Gradient(g) = crate::panels::current_paints(h.state()).0 else { panic!("still a gradient") }; + let hue = hsb(&g.gradient.stops[0].color)[0]; + assert!(!(1.0..359.0).contains(&hue), "the new gradient's white stop has hue 0°, not 120°: {hue}"); +} + +/// The remembered HSB holds only while the color displays as it did when picked: in another +/// working RGB space the wheel shows the color's own HSB there. +#[test] +fn the_remembered_hsb_follows_a_change_of_working_space() { + let srgb = Cms::default(); + let p3 = Cms::new(&ColorSettings { rgb: cms::DISPLAY_P3.into(), ..ColorSettings::default() }).unwrap(); + let c = Color::rgb(0.5, 0.3, 0.2); + let picked = [25.0, 0.6, 0.5]; + let shown = srgb.display_rgb(&c); + assert_eq!(remembered_hsb(&srgb, &c, shown, picked), picked); + let own = display_hsb(&p3, &c); + assert_ne!(own, picked); + assert_eq!(remembered_hsb(&p3, &c, shown, picked), own); +} + +/// A CMYK fill stays CMYK and keeps its look (ΔE 2000 under 2) when its marker is clicked; the +/// working profile separates the picked color again, so its inks can move. +#[test] +fn a_cmyk_fill_stays_cmyk_and_keeps_its_look() { + let [c, m, y, k] = Color::rgb(0.2, 0.5, 0.7).to_cmyk_managed(cms::active().settings().intent); + for inks in [[c, m, y, k], [0.2, 0.2, 0.2, 0.0]] { + let mut h = harness(app(json!({"c": inks[0], "m": inks[1], "y": inks[2], "k": inks[3]}))); + let before = fill(h.state()); + let [_, s, b] = hsb(&before); + let sq = rect_of(&h, "Saturation and brightness"); + click(&mut h, in_square(sq, s, b), Modifiers::NONE); + let after = fill(h.state()); + assert!(matches!(after, Color::Cmyk { .. }), "{after:?}"); + let cms = cms::active(); + assert!(delta_e2000(cms.lab(&before), cms.lab(&after)) < cms::GAMUT_THRESHOLD, "{before:?} → {after:?}"); + } +} + +/// A Gray fill becomes a process color in the document's color mode. +#[test] +fn a_gray_fill_becomes_rgb_or_cmyk_with_the_document() { + for (mode, rgb) in [("rgb", true), ("cmyk", false)] { + let mut app = app(json!("#ff0000")); + app.run("file.documentColorMode", json!({"mode": mode})).unwrap(); + app.run("paint.setFill", json!({"color": {"gray": 0.5}})).unwrap(); + let mut h = harness(app); + let ring = rect_of(&h, "Hue ring"); + click(&mut h, on_ring(ring, 0.0, 0.0), Modifiers::NONE); + let c = fill(h.state()); + assert_eq!(matches!(c, Color::Rgb { .. }), rgb, "{mode}: {c:?}"); + assert_eq!(matches!(c, Color::Cmyk { .. }), !rgb, "{mode}: {c:?}"); + } +} + +/// In a working RGB space wider than sRGB a pick goes into it through the color settings, so a hue +/// change keeps the displayed saturation and brightness. +#[test] +fn a_pick_in_display_p3_keeps_the_components_it_does_not_change() { + let p3 = Cms::new(&ColorSettings { rgb: cms::DISPLAY_P3.into(), ..ColorSettings::default() }).unwrap(); + let was = Color::rgb(0.5, 0.3, 0.2); + let [h, s, b] = display_hsb(&p3, &was); + let new = stored(&p3, [h + 40.0, s, b], Some(&was), false); + let [h2, s2, b2] = display_hsb(&p3, &new); + assert!(matches!(new, Color::Rgb { .. })); + assert!((h2 - (h + 40.0)).abs() < 1.0 && (s2 - s).abs() < 0.01 && (b2 - b).abs() < 0.01, "{:?} → {:?}", [h, s, b], [h2, s2, b2]); +} + +/// A press gives the ring or the square the keyboard focus. The arrows then step the color, each +/// press one undo step, the app's tool keys wait, and after Escape they work again. +#[test] +fn the_arrow_keys_step_the_focused_ring_or_square() { + let mut h = harness(app(json!("#ff0000"))); + let ring = rect_of(&h, "Hue ring"); + click(&mut h, on_ring(ring, 0.0, 0.0), Modifiers::NONE); + let undo = undo_len(h.state()); + key(&mut h, egui::Key::ArrowRight, Modifiers::NONE); + assert!(near(hsb(&fill(h.state())), [1.0, 1.0, 1.0], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); + key(&mut h, egui::Key::ArrowUp, Modifiers::SHIFT); + assert!(near(hsb(&fill(h.state())), [11.0, 1.0, 1.0], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); + assert_eq!(undo_len(h.state()), undo + 2, "a step each"); + key(&mut h, egui::Key::ArrowLeft, Modifiers::SHIFT); + key(&mut h, egui::Key::ArrowDown, Modifiers::NONE); + key(&mut h, egui::Key::ArrowDown, Modifiers::NONE); + assert!(near(hsb(&fill(h.state())), [359.0, 1.0, 1.0], [1e-3, 1e-3, 1e-3]), "wraps: {:?}", hsb(&fill(h.state()))); + let sq = rect_of(&h, "Saturation and brightness"); + click(&mut h, in_square(sq, 0.5, 0.5), Modifiers::NONE); + let before = hsb(&fill(h.state())); + key(&mut h, egui::Key::ArrowUp, Modifiers::NONE); + key(&mut h, egui::Key::ArrowRight, Modifiers::NONE); + let after = hsb(&fill(h.state())); + assert!(near(after, [before[0], before[1] + 0.01, before[2] + 0.01], [1e-3, 1e-4, 1e-4]), "{before:?} → {after:?}"); + // The tool keys wait while a control has the focus. + let tool = h.state().session.tool_id().to_string(); + let p = |h: &mut Harness<'static, VectorcraftApp>| { + h.event(Event::Key { key: egui::Key::P, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.event(Event::Text("p".into())); + h.run_steps(1); + }; + p(&mut h); + assert_eq!(h.state().session.tool_id(), tool); + key(&mut h, egui::Key::Escape, Modifiers::NONE); + p(&mut h); + assert_eq!(h.state().session.tool_id(), "pen"); +} + +/// Screen readers find the ring as a slider from 0 to 360 that their actions move, and the square +/// with both of its components, whose actions move its saturation as → and ← do. +#[test] +fn screen_readers_read_and_move_the_ring_and_the_square() { + let mut h = harness(app(json!([0.6, 0.3, 0.3]))); + let ring = h.get_by_label("Hue ring"); + let node = ring.accesskit_node(); + assert_eq!((node.min_numeric_value(), node.max_numeric_value(), node.numeric_value_step()), (Some(0.0), Some(360.0), Some(1.0))); + assert!([Action::Increment, Action::Decrement, Action::SetValue].iter().all(|a| node.data().supports_action(*a))); + assert_eq!(node.numeric_value(), Some(0.0)); + let (target_node, target_tree) = node.locate(); + assert_eq!(h.get_by_label("Saturation and brightness").value().as_deref(), Some("Saturation 50%, Brightness 60%")); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::Increment, data: None })); + h.run_steps(1); + assert!(near(hsb(&fill(h.state())), [1.0, 0.5, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); + let data = Some(ActionData::NumericValue(200.0)); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::SetValue, data })); + h.run_steps(1); + assert!(near(hsb(&fill(h.state())), [200.0, 0.5, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); + let square = h.get_by_label("Saturation and brightness"); + let node = square.accesskit_node(); + assert_eq!((node.min_numeric_value(), node.max_numeric_value(), node.numeric_value_step()), (Some(0.0), Some(100.0), Some(1.0))); + assert!([Action::Increment, Action::Decrement, Action::SetValue].iter().all(|a| node.data().supports_action(*a))); + assert!(node.numeric_value().is_some_and(|v| (v - 50.0).abs() < 1e-3), "{:?}", node.numeric_value()); + let (target_node, target_tree) = node.locate(); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::Increment, data: None })); + h.run_steps(1); + assert!(near(hsb(&fill(h.state())), [200.0, 0.51, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); + let data = Some(ActionData::NumericValue(20.0)); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::SetValue, data })); + h.run_steps(1); + assert!(near(hsb(&fill(h.state())), [200.0, 0.2, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::Decrement, data: None })); + h.run_steps(1); + assert!(near(hsb(&fill(h.state())), [200.0, 0.19, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); +} + +#[test] +fn a_huge_set_value_on_the_ring_wraps_like_any_other_hue() { + let mut h = harness(app(json!([0.3, 0.6, 0.3]))); + let (target_node, target_tree) = h.get_by_label("Hue ring").accesskit_node().locate(); + let data = Some(ActionData::NumericValue(1e300)); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::SetValue, data })); + h.run_steps(1); + let color = fill(h.state()); + let Color::Rgb { r, g, b } = color else { panic!("expected RGB fill: {color:?}") }; + assert!([r, g, b].iter().all(|v| v.is_finite()), "{color:?}"); + assert!(hsb(&color).iter().all(|v| v.is_finite()), "{:?}", hsb(&color)); + assert!(near(hsb(&color), [0.0, 0.5, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&color)); +} + +/// A step past the edge of the square leaves the fill alone: a CMYK cyan shows at 100% saturation, +/// and a step up from there neither separates it again nor adds an undo step. +#[test] +fn a_step_past_the_edge_of_the_square_changes_nothing() { + let mut h = harness(app(json!({"c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}))); + let before = fill(h.state()); + assert_eq!(hsb(&before)[1], 1.0, "{:?}", hsb(&before)); + let undo = undo_len(h.state()); + let (target_node, target_tree) = h.get_by_label("Saturation and brightness").accesskit_node().locate(); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::Increment, data: None })); + h.run_steps(1); + assert_eq!(fill(h.state()), before); + assert_eq!(undo_len(h.state()), undo, "no undo step"); +} + +/// The Color Picker's color field is the same widget: a press focuses it, the arrows step the +/// components it shows, and screen readers read its name and both components and step the first. +#[test] +fn the_color_pickers_field_takes_the_arrow_keys() { + let mut app = app(json!([0.6, 0.3, 0.3])); + app.run("ui.colorPicker", json!({})).unwrap(); + let mut fonts = false; + let mut h = Harness::builder().with_size(vec2(1000.0, 800.0)).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + // The fonts take effect from the next frame. + if !fonts { + crate::theme::install_fonts(ui.ctx()); + fonts = true; + return; + } + crate::dialogs::show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + let field = rect_of(&h, "Color field"); + assert_eq!(h.get_by_label("Color field").value().as_deref(), Some("Saturation 50%, Brightness 60%")); + click(&mut h, field.center(), Modifiers::NONE); + let picked = |h: &Harness<'static, VectorcraftApp>| -> [f32; 3] { + serde_json::from_value(h.state().ui.dialog.as_ref().unwrap().fields["__hsb"].clone()).unwrap() + }; + let before = picked(&h); + key(&mut h, egui::Key::ArrowRight, Modifiers::NONE); + key(&mut h, egui::Key::ArrowDown, Modifiers::SHIFT); + let after = picked(&h); + assert!(near(after, [before[0], before[1] + 0.01, before[2] - 0.1], [1e-3, 1e-4, 1e-4]), "{before:?} → {after:?}"); + let (target_node, target_tree) = h.get_by_label("Color field").accesskit_node().locate(); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::Increment, data: None })); + h.run_steps(1); + let stepped = picked(&h); + assert!(near(stepped, [after[0], after[1] + 0.01, after[2]], [1e-3, 1e-4, 1e-4]), "{after:?} → {stepped:?}"); + assert!(h.state().ui.dialog.is_some(), "the dialog stays open"); +} diff --git a/crates/ui-egui/src/panels/text_styles.rs b/crates/ui-egui/src/panels/text_styles.rs index eb2908400..cee836a38 100644 --- a/crates/ui-egui/src/panels/text_styles.rs +++ b/crates/ui-egui/src/panels/text_styles.rs @@ -1,9 +1,12 @@ //! Character Styles and Paragraph Styles panels (one implementation, two kinds). //! //! Click a style to apply it to the selected text (or the Type tool's selected characters); -//! Alt-click applies it and clears overrides. The style of the selection is highlighted, with a -//! "+" when the text overrides some of its attributes. New makes a style from the selected text; -//! Alt-click on New first asks for its name (`ui.newCharStyle`, `ui.newParaStyle`). +//! Alt-click applies it and clears overrides. Double-clicking a character style's row opens its +//! Character Style Options (`ui.charStyleOptions`), as the panel menu does; a paragraph style's row +//! opens its Paragraph Style Options, the rename dialog the panel menu opens. The style of the +//! selection is highlighted, with a "+" when the text overrides some of its attributes. New makes +//! a style from the selected text; Alt-click on New first asks for its name (`ui.newCharStyle`, +//! `ui.newParaStyle`). use std::cell::RefCell; @@ -225,6 +228,19 @@ fn row_look(is_cur: bool, in_highlight: bool, hovered: bool, t: &Tokens) -> (Opt (fill, is_cur && in_highlight) } +/// Opens `name`'s options: Character Style Options for a character style, or the Paragraph Style +/// Options the Paragraph Styles panel menu opens, which rename the style (`ui.paramDialog` runs +/// `paraStyle.rename`). +fn open_style_options(app: &mut VectorcraftApp, kind: Kind, name: &str) { + if kind == Kind::Char { + app.run("ui.charStyleOptions", json!({ "name": name })).ok(); + } else { + let label = format!("{} Style Options", kind.what()); + app.run("ui.paramDialog", json!({ "command": format!("{}.rename", kind.prefix()), "label": label, "params": { "name": name, "to": name } })) + .ok(); + } +} + /// The style list and its New and Delete buttons. Shift-click highlights every style from the /// last one clicked without Shift through this one, and Cmd/Ctrl-click adds or removes one; both /// leave the text as it is. Delete deletes every highlighted style but the Normal style. @@ -235,6 +251,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { let normal = all.first().map(|s| s.0.clone()).unwrap_or_default(); let cur_name = cur.as_ref().map(|(n, _)| n.clone().unwrap_or_else(|| normal.clone())); let mut clicked: Option<(String, egui::Modifiers)> = None; + // A double-clicked Character Style's row, whose options open after the list is laid out. + let mut options: Option = None; + // Set on a double-click's second click, which must not apply the style again. + let mut double_click = false; let listed: Vec<&str> = all.iter().map(|s| s.0.as_str()).collect(); // A highlighted style that is no longer listed (renamed in Style Options, deleted by a command // or undone) is no longer highlighted. @@ -279,6 +299,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { if resp.clicked() { clicked = Some((name.clone(), ui.input(|i| i.modifiers))); } + // A double-click on a user character style opens its Character Style Options, as the + // panel menu's item does; with Shift, Cmd/Ctrl or Alt held it only follows the click + // rules. The second click never applies the style again, also on the Normal style, + // which has no options to open. + if resp.double_clicked() { + double_click = true; + let m = ui.input(|i| i.modifiers); + if *name != normal && !(m.shift || m.command || m.alt) { + options = Some(name.clone()); + } + } } }); if scroll_to.is_some() { @@ -287,12 +318,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { let has_text = cur.is_some(); if let Some((name, m)) = clicked { sel = super::swatches::click_selection(ui, kind.anchor_key(), sel, &listed, &name, m); - // Shift and Cmd/Ctrl clicks only highlight, as in the Graphic Styles panel. - if has_text && !(m.shift || m.command) { + // Shift and Cmd/Ctrl clicks only highlight, as in the Graphic Styles panel. The second + // click of a double-click opens the options instead of applying the style again. + if has_text && !(m.shift || m.command) && options.is_none() && !double_click { let p = apply_target(app, kind, json!({ "name": name, "clearOverrides": m.alt })); app.run(&format!("{}.apply", kind.prefix()), p).ok(); } } + if let Some(name) = options { + open_style_options(app, kind, &name); + } set_highlighted(ui.ctx(), kind, sel.clone()); let doomed: Vec<&String> = sel.iter().filter(|s| **s != normal).collect(); let deletable = !doomed.is_empty(); @@ -389,12 +424,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { if menu_item(ui, &crate::i18n::fmt(tl!("{kind} Style Options…"), &[("kind", kind.what_label())]), user, false) && let Some(n) = &sel { - if kind == Kind::Char { - app.run("ui.charStyleOptions", json!({ "name": n })).ok(); - } else { - let label = format!("{} Style Options", kind.what()); - app.run("ui.paramDialog", json!({ "command": format!("{p}.rename"), "label": label, "params": { "name": n, "to": n } })).ok(); - } + open_style_options(app, kind, n); } ui.separator(); // As commands, so a file dialog shown off the UI thread loads the styles when it answers. @@ -421,7 +451,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/crates/ui-egui/src/panels/text_styles/tests.rs b/crates/ui-egui/src/panels/text_styles/tests.rs index f3218bc35..2fe4ab11f 100644 --- a/crates/ui-egui/src/panels/text_styles/tests.rs +++ b/crates/ui-egui/src/panels/text_styles/tests.rs @@ -261,6 +261,66 @@ fn click_label(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str, mut d app.run_count.wrapping_sub(before) } +/// Double-clicking a user character style's row opens its Character Style Options once: the first +/// click applies the style to the selected text, the second opens the dialog without applying it +/// again. A paragraph style's row opens none, and the built-in Normal style opens none either. +#[test] +fn double_clicking_a_character_style_row_opens_its_options() { + for kind in [Kind::Char, Kind::Para] { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 40, "text": "Hi"})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run(&format!("{}.new", kind.prefix()), json!({"name": "Body"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| show(app, ui, kind); + frame_events(&ctx, &mut app, vec![], &mut draw); + let texts = frame_events(&ctx, &mut app, vec![], &mut draw); + let pos = text_rect(&texts, "Body").center(); + let before = app.run_count; + click(&ctx, &mut app, pos, &mut draw); + click(&ctx, &mut app, pos, &mut draw); + let dialog = app.ui.dialog.as_ref(); + if kind == Kind::Char { + let d = dialog.expect("double-clicking a Character Style's row opens its options"); + assert_eq!(d.kind, "charStyleOptions"); + assert_eq!(d.str("__style"), "Body"); + // One apply from the first click and the dialog from the second: no second apply. + assert_eq!(app.run_count.wrapping_sub(before), 2, "one apply and the dialog"); + } else { + let d = dialog.expect("double-clicking a Paragraph Style's row opens its options"); + assert_eq!(d.kind, "command"); + assert_eq!(d.str("__command"), "paraStyle.rename"); + assert_eq!(d.str("__label"), "Paragraph Style Options"); + // One apply from the first click and the rename dialog from the second. + assert_eq!(app.run_count.wrapping_sub(before), 2, "one apply and the dialog"); + } + } +} + +/// A double-click on the built-in Normal style opens no options, and its second click applies it +/// only once, as a user style's does. +#[test] +fn double_clicking_the_normal_style_opens_no_options() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 40, "text": "Hi"})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| show(app, ui, Kind::Char); + frame_events(&ctx, &mut app, vec![], &mut draw); + let texts = frame_events(&ctx, &mut app, vec![], &mut draw); + let normal = texts.iter().find(|(text, _)| text.contains("Normal")).map(|(_, r)| r.center()); + let pos = normal.expect("the Normal style's row is drawn"); + let before = app.run_count; + click(&ctx, &mut app, pos, &mut draw); + click(&ctx, &mut app, pos, &mut draw); + assert!(app.ui.dialog.is_none(), "the Normal style has no Character Style Options"); + assert_eq!(app.run_count.wrapping_sub(before), 1, "the second click applies nothing"); +} + /// The panel menu's Duplicate highlights the copy and its Delete clears the highlight, as the /// panel's New and Delete buttons do; after a menu delete, Duplicate runs nothing. #[test] diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index ee3ce335c..b1f8322fd 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -1,8 +1,8 @@ //! Transform panel: reference point, X/Y/W/H with the constrain link, rotate and shear, flips, -//! live-shape properties and the Scale Corners / Scale Strokes & Effects options. While the -//! Artboard tool is in use it shows and edits the active artboard's X/Y/W/H instead (a new W or H -//! resizes every selected artboard), and with only ruler guides selected their X (vertical guides) -//! and Y (horizontal ones). +//! live-shape properties and the Scale Corners / Scale Strokes & Effects / Align to Pixel Grid +//! options. While the Artboard tool is in use it shows and edits the active artboard's X/Y/W/H +//! instead (a new W or H resizes every selected artboard), and with only ruler guides selected +//! their X (vertical guides) and Y (horizontal ones). use egui::Ui; use serde_json::json; @@ -363,6 +363,32 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::check(ui, tl!("Scale Strokes & Effects"), ss, true) { set_pref(app, "scaleStrokes", !ss); } + pixel_grid_check(app, ui); +} + +/// The selected objects' Align to Pixel Grid: on or off when they agree, `None` when they differ, +/// with whether every one can be aligned (a path that is not a guide and not inside a compound path, +/// or a compound path) and their ids. `None` with nothing selected. +fn pixel_aligned_state(app: &VectorcraftApp) -> Option<(Option, bool, Vec)> { + let st = app.session.active()?; + let nodes: Vec<&Node> = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).collect(); + let first = nodes.first()?.extras().pixel_aligned; + let value = nodes.iter().all(|n| n.extras().pixel_aligned == first).then_some(first); + let in_compound = |n: &Node| st.doc.parent_of(n.id).and_then(|p| st.doc.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. })); + let alignable = nodes.iter().all(|n| matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) && !in_compound(n)); + Some((value, alignable, nodes.iter().map(|n| n.id.0).collect())) +} + +/// The Align to Pixel Grid checkbox (`object.setPixelAligned`): a dash when the selected objects +/// differ, and unavailable unless every selected object can be aligned. A click turns it on for +/// them all, or off when it is on for them all. +fn pixel_grid_check(app: &mut VectorcraftApp, ui: &mut Ui) { + let (value, alignable, ids) = pixel_aligned_state(app).unwrap_or((Some(false), false, vec![])); + if widgets::check3(ui, tl!("Align to Pixel Grid"), value, alignable) + && let Err(e) = app.run("object.setPixelAligned", json!({"ids": ids, "on": value != Some(true)})) + { + app.status(e); + } } /// A live rectangle's, polygon's or ellipse's properties (Transform and Properties panels, and the diff --git a/crates/ui-egui/src/panels/transform/tests.rs b/crates/ui-egui/src/panels/transform/tests.rs index a945f9ec1..b5598689a 100644 --- a/crates/ui-egui/src/panels/transform/tests.rs +++ b/crates/ui-egui/src/panels/transform/tests.rs @@ -503,3 +503,47 @@ fn rotate_and_shear_fields_are_named() { let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Rotate"); let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Shear"); } + +/// The Align to Pixel Grid checkbox turns the selected paths' pixel-aligned property on, shows a +/// dash when they differ (a click then turns it on for both), turns it off when it is on for all, +/// and is unavailable with type selected. +#[test] +fn align_to_pixel_grid_shows_and_sets_the_property() { + use crate::panels::tests_appearance::{click, frame_events, text_rect}; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let a = app.run("shape.rectangle", json!({"x": 10.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap(); + let b = app.run("shape.rectangle", json!({"x": 60.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let aligned = |app: &VectorcraftApp, id: u64| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().extras().pixel_aligned; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let toggle = |ctx: &egui::Context, app: &mut VectorcraftApp| { + let texts = frame_events(ctx, app, vec![], show); + click(ctx, app, text_rect(&texts, "Align to Pixel Grid").center(), show); + }; + assert_eq!(pixel_aligned_state(&app).map(|s| (s.0, s.1)), Some((Some(false), true))); + toggle(&ctx, &mut app); + assert!(aligned(&app, a) && aligned(&app, b)); + let x0 = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(a)).unwrap().geometric_bounds().unwrap().x0; + // The default 1 pt stroke is odd and centered: the edges go to half points. + assert_eq!(x0, 10.5); + app.run("object.setPixelAligned", json!({"ids": [b], "on": false})).unwrap(); + assert_eq!(pixel_aligned_state(&app).map(|s| s.0), Some(None)); + toggle(&ctx, &mut app); + assert!(aligned(&app, a) && aligned(&app, b)); + toggle(&ctx, &mut app); + assert!(!aligned(&app, a) && !aligned(&app, b)); + let t = app.run("text.create", json!({"x": 10, "y": 100, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [a, t]})).unwrap(); + assert_eq!(pixel_aligned_state(&app).map(|s| s.1), Some(false)); + toggle(&ctx, &mut app); + assert!(!aligned(&app, a)); + // A path inside a compound path snaps with the compound path, so it cannot be aligned alone. + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let c = app.run("object.compoundPath.make", json!({})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(pixel_aligned_state(&app).map(|s| s.1), Some(true)); + let member = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(c)).unwrap().children().unwrap()[0].id.0; + app.run("select.set", json!({"ids": [member]})).unwrap(); + assert_eq!(pixel_aligned_state(&app).map(|s| s.1), Some(false)); +} 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/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index d4f4412ec..ebb42852a 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -444,12 +444,12 @@ impl VectorcraftApp { /// onto the canvas of the open document (Shift embeds it), else opened. Where the platform /// doesn't say (`None`: desktop drags carry no position), a document opens as a tab of its /// own and a picture or text is placed in the middle of the view. A library, a presets file, a - /// plug-in, a shortcut set or a script opens wherever it is dropped, as File → Open opens it - /// ([`io::opens_as`]). + /// plug-in, a shortcut set, a script or a file only an importer plug-in reads opens wherever it + /// is dropped, as File → Open opens it ([`io::opens_as`], [`fileio::opens_with_importer`]). pub fn drop_target(&self, name: &str, pos: Option, shift: bool) -> DropTarget { let (Some(rect), Some(st), Some(v)) = (self.canvas_rect, self.session.active(), self.view()) else { return DropTarget::Open }; let at = match pos { - _ if io::opens_as(name).is_some() => return DropTarget::Open, + _ if io::opens_as(name).is_some() || fileio::opens_with_importer(name) => return DropTarget::Open, Some(p) if !rect.contains(p) => return DropTarget::Open, Some(p) => crate::canvas::Xf::new(rect, v).to_doc(p), None if is_document(name) => return DropTarget::Open, diff --git a/crates/ui-egui/src/placing.rs b/crates/ui-egui/src/placing.rs index 7bdc1f9d6..3eacb2832 100644 --- a/crates/ui-egui/src/placing.rs +++ b/crates/ui-egui/src/placing.rs @@ -192,6 +192,10 @@ pub fn available(app: &VectorcraftApp) -> bool { /// checked here first, so a bad one is an error at once. When no thread could start, the file is /// placed at once → `file.place`'s reply. pub fn start(app: &mut VectorcraftApp, p: &Value, dropped: bool) -> Result { + // The edit the tool computes on another thread comes first: the object Replace swaps is the + // one selected after it. + let r = app.session.finish_tool_edit(); + crate::canvas::apply_requests(app, r); let job = app.session.prepare_place(p).map_err(|e| e.to_string())?; let name = job.name(); let started = spawn(app, name.clone(), name, dropped, move || job.read().map(|r| Read::Place(Box::new(r))).map_err(|e| e.to_string()))?; diff --git a/crates/ui-egui/src/placing/tests.rs b/crates/ui-egui/src/placing/tests.rs index fcd209016..96492a651 100644 --- a/crates/ui-egui/src/placing/tests.rs +++ b/crates/ui-egui/src/placing/tests.rs @@ -1166,6 +1166,46 @@ fn a_replace_whose_object_was_deleted_meanwhile_places_nothing() { assert!(state(&app) == cleared, "nothing placed"); } +/// A Replace started while a Shape Builder merge is computed on another thread swaps the object +/// as the merge leaves it: the merge is applied first. +#[test] +fn a_replace_during_a_merge_on_another_thread_swaps_the_merged_object() { + use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + let path = saved("replace-merge", "photo.tif", &picture(Tiff, 60, 40)); + let mut app = app(true); + for k in 0..40 { + app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap(); + } + app.run("select.all", json!({})).unwrap(); + app.run("object.group", json!({})).unwrap(); + app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap(); + let view = app.view_info(); + let at = |kind, x| PointerEvent { + kind, + pos: vectorcraft_geom::Point::new(x, 10.0), + mods: Mods::default(), + pressure: 1.0, + backquote: false, + pen: None, + }; + crate::canvas::dispatch(&mut app, &at(PointerKind::Move, 5.0), view); + app.session.tool_poll(view, true).unwrap(); + // A drag across the row merges every square into one object, the one Replace swaps. + for (kind, x) in [(PointerKind::Down, 5.0), (PointerKind::Drag, 200.0), (PointerKind::Drag, 405.0), (PointerKind::Up, 405.0)] { + crate::canvas::dispatch(&mut app, &at(kind, x), view); + } + assert!(app.session.tool_editing()); + start(&mut app, &json!({"path": path, "replace": true}), false).unwrap(); + assert!(!app.session.tool_editing(), "the merge is applied first"); + placed(&mut app); + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + let mut placed_names = vec![]; + app.session.doc().unwrap().doc.walk(|n| placed_names.extend(n.name.clone())); + assert!(placed_names.iter().any(|n| n == "photo.tif"), "the merged art is replaced: {placed_names:?}"); + let labels: Vec = app.session.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.clone()).collect(); + assert_eq!(labels.last().map(String::as_str), Some("Shape Builder"), "{labels:?}"); +} + /// A file read while a dialog is open waits for the dialog to close, as a document that opens /// does (#1694): the dialog's OK acts on the active document. #[test] diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index c3532da83..fad2d45e7 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -19,6 +19,10 @@ pub const UI_FIELDS: &[(&str, &str, &str)] = &[ ("__taskBar", "General", "Enable Contextual Task Bar"), ]; +/// Whether User Interface shows VectorCraft Title Bar on Every Desktop (`appTitleBar`): Linux and the +/// BSDs, where the desktop may draw the window's frame. macOS and Windows never read it. +const APP_TITLE_BAR_SHOWN: bool = cfg!(not(any(target_arch = "wasm32", target_os = "macos", target_os = "windows"))); + pub fn open(app: &mut VectorcraftApp, category: Option<&str>) { // One field per preference key or group (the flattener presets have commands of their own). let mut fields: Map = app.session.prefs.to_json().as_object().cloned().unwrap_or_default(); @@ -32,6 +36,9 @@ pub fn open(app: &mut VectorcraftApp, category: Option<&str>) { fields.insert("__smartGuides".into(), json!(app.ui.view.smart_guides)); fields.insert("__snapToGrid".into(), json!(app.ui.view.snap_to_grid)); fields.insert("__taskBar".into(), json!(app.ui.task_bar)); + // Read-only: whether VECTORCRAFT_TITLEBAR chose this window's frame (System Title Bar's dimmed + // row and VectorCraft Title Bar on Every Desktop's note). + fields.insert("__titlebarFromEnv".into(), json!(app.titlebar_from_env)); fields.insert("__search".into(), json!("")); fields.insert("__focusSearch".into(), json!(true)); app.ui.dialog = Some(Dialog { kind: "preferences".into(), fields }); @@ -50,9 +57,12 @@ pub(crate) struct Setting { /// preferences, each category's in the order the dialog lists them. fn settings() -> impl Iterator { let ui_fields = UI_FIELDS.iter().map(|&(key, category, label)| Setting { key, category, section: "", label }); + const SYSTEM_TITLE_BAR: bool = cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))); + const DISPLAY_SERVER_CHOICE: bool = cfg!(target_os = "linux"); let shown = |key: &str| match key { - "systemTitleBar" => cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))), - "linuxDisplayServer" => cfg!(target_os = "linux"), + "systemTitleBar" => SYSTEM_TITLE_BAR, + "appTitleBar" => APP_TITLE_BAR_SHOWN, + "linuxDisplayServer" => DISPLAY_SERVER_CHOICE, _ => true, }; let prefs = @@ -456,6 +466,11 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, let v = d.fields.get(sp.key).cloned().unwrap_or(Value::Null); let mark = found_mark(ui, d, sp.key); match sp.kind { + // General › Scroll Sideways with Cmd/Ctrl + Wheel acts only while Zoom with Mouse Wheel + // is off: dimmed while it is on. + PrefKind::Bool if sp.key == "ctrlWheelScrollsSideways" => { + ui.add_enabled_ui(!d.bool("zoomWithMouseWheel"), |ui| bool_row(ui, d, sp.key, sp.label)); + } // Units › Numbers Without Units Are Points only tells points from picas: dimmed // unless a unit is Picas. PrefKind::Bool if sp.key == "numbersWithoutUnitsArePoints" => { @@ -468,17 +483,45 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, // Windows and Linux only: macOS always has the system's title bar, the web has none. PrefKind::Bool if sp.key == "systemTitleBar" => { if cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) { - bool_row(ui, d, sp.key, sp.label); - // The window's decorations are chosen once, when the app starts. - ui.label(egui::RichText::new(tl!("Applies the next time VectorCraft starts.")).color(t.text_dim).size(11.0)); + // VECTORCRAFT_TITLEBAR chooses the frame while it is set: the checkbox is dimmed. + ui.add_enabled_ui(!d.bool("__titlebarFromEnv"), |ui| bool_row(ui, d, sp.key, sp.label)); + // The window's decorations are chosen once, when the app starts. On Linux and the + // BSDs the note goes under the next row, which also applies at the next start. + if !APP_TITLE_BAR_SHOWN { + ui.label(egui::RichText::new(tl!("Applies the next time VectorCraft starts.")).color(t.text_dim).size(11.0)); + } + } + } + // Linux and the BSDs only, where the desktop may draw the frame: dimmed while System + // Title Bar is on or VECTORCRAFT_TITLEBAR is set to `app`, `system` or `desktop`, which + // take precedence over it. A note + // names each one that is in effect. + PrefKind::Bool if sp.key == "appTitleBar" => { + if APP_TITLE_BAR_SHOWN { + let from_env = d.bool("__titlebarFromEnv"); + let system = d.bool("systemTitleBar"); + ui.add_enabled_ui(!from_env && !system, |ui| bool_row(ui, d, sp.key, sp.label)); + if system && !from_env { + let note = tl!("System Title Bar takes precedence over this option while it is on."); + ui.label(egui::RichText::new(note).color(t.text_dim).size(11.0)); + } + let note = if from_env { + tl!("The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set.") + } else { + tl!("Applies the next time VectorCraft starts.") + }; + ui.label(egui::RichText::new(note).color(t.text_dim).size(11.0)); } } PrefKind::Bool => bool_row(ui, d, sp.key, sp.label), PrefKind::Num { min, max, unit } => { - labeled(ui, sp.label, |ui| { + labeled(ui, sp.label, |ui, label_id| { let mut x = v.as_f64().unwrap_or(min); let new = if sp.key == "uiScaling" { - widgets::slider(ui, egui::Slider::new(&mut x, min..=max).step_by(0.05).text(tl!("Smaller ↔ Larger"))).changed().then_some(x) + // Without internal text, both slider controls inherit the row's name across focus changes. + let response = widgets::slider(ui, egui::Slider::new(&mut x, min..=max).step_by(0.05)).labelled_by(label_id); + ui.add(egui::Label::new(tl!("Smaller ↔ Larger")).wrap_mode(egui::TextWrapMode::Extend)); + response.changed().then_some(x) } else { widgets::range_field(ui, sp.key, x, min..=max, &format!(" {unit}"), 3, 110.0) }; @@ -491,17 +534,19 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, // In the dialog's own Units choice, so a change there shows right away. let unit = vectorcraft_doc::Unit::named(&d.str(measure.pref_key())).unwrap_or_default(); let x = d.f64(sp.key, min); - labeled(ui, sp.label, |ui| { + labeled(ui, sp.label, |ui, _| { if let Some(x) = widgets::num_field(ui, sp.key, Some(x), unit, 110.0) { d.fields.insert(sp.key.into(), json!(x.clamp(min, max))); } }); } PrefKind::Int { min, max } => { - labeled(ui, sp.label, |ui| { + labeled(ui, sp.label, |ui, label_id| { let mut x = v.as_i64().unwrap_or(min); let new = if sp.key == "anchorSize" { - widgets::slider(ui, egui::Slider::new(&mut x, min..=max).show_value(false).text(tl!("Size"))).changed().then_some(x) + let response = widgets::slider(ui, egui::Slider::new(&mut x, min..=max).show_value(false)).labelled_by(label_id); + ui.add(egui::Label::new(tl!("Size")).wrap_mode(egui::TextWrapMode::Extend)); + response.changed().then_some(x) } else { widgets::range_field(ui, sp.key, x as f64, min as f64..=max as f64, "", 0, 110.0).map(|x| x as i64) }; @@ -533,11 +578,11 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, } } PrefKind::Color => { - labeled(ui, sp.label, |ui| { + labeled(ui, sp.label, |ui, label_id| { let hex = v.as_str().unwrap_or("#000000"); let c = vectorcraft_color::Color::from_hex(hex).map(|c| c.to_rgba8(1.0)).unwrap_or([0, 0, 0, 255]); let mut rgb = [c[0], c[1], c[2]]; - if ui.color_edit_button_srgb(&mut rgb).changed() { + if ui.color_edit_button_srgb(&mut rgb).labelled_by(label_id).changed() { d.fields.insert(sp.key.into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); } ui.label(egui::RichText::new(hex).color(t.text_dim).size(11.0)); @@ -545,7 +590,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, } PrefKind::Text if sp.key == "interfaceLanguage" => { // `auto` plus every registered language, by its own name. - labeled(ui, sp.label, |ui| { + labeled(ui, sp.label, |ui, _| { let cur = v.as_str().unwrap_or("auto"); let codes: Vec<&str> = std::iter::once("auto").chain(crate::i18n::Lang::for_picker().into_iter().map(|l| l.code())).collect(); let labels: Vec<&str> = @@ -562,7 +607,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, // Plug-ins Folder: typed, or picked with Choose… where the host has a folder picker // (not on the web). PrefKind::Text if folder_check(sp.key).is_some() => { - labeled(ui, sp.label, |ui| { + labeled(ui, sp.label, |ui, _| { let mut s = v.as_str().unwrap_or("").to_string(); if ui.add(egui::TextEdit::singleline(&mut s).align(egui::Align2::LEFT_CENTER).desired_width(200.0)).changed() { d.fields.insert(sp.key.into(), json!(s)); @@ -573,7 +618,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, }); } PrefKind::Text => { - labeled(ui, sp.label, |ui| { + labeled(ui, sp.label, |ui, _| { let mut s = v.as_str().unwrap_or("").to_string(); if ui.add(egui::TextEdit::singleline(&mut s).align(egui::Align2::LEFT_CENTER).desired_width(260.0)).changed() { d.fields.insert(sp.key.into(), json!(s)); @@ -608,7 +653,7 @@ fn picas_in_use(d: &Dialog) -> bool { } fn choice_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, opts: &[(&str, &str)], v: &Value) { - labeled(ui, label, |ui| { + labeled(ui, label, |ui, _| { let cur = v.as_str().unwrap_or(""); let cur_label = opts.iter().find(|o| o.0 == cur).map(|o| o.1).unwrap_or(cur); let labels: Vec<&str> = opts.iter().map(|o| o.1).collect(); @@ -628,14 +673,16 @@ fn bool_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { } } -fn labeled(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) { +fn labeled(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui, egui::Id)) { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { - ui.allocate_ui_with_layout(egui::vec2(210.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { - ui.set_min_width(210.0); - ui.add(egui::Label::new(egui::RichText::new(format!("{}{}", tl!(label), tl!(":"))).color(t.text)).truncate()); - }); - add(ui); + let label = ui + .allocate_ui_with_layout(egui::vec2(210.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(210.0); + ui.add(egui::Label::new(egui::RichText::new(format!("{}{}", tl!(label), tl!(":"))).color(t.text)).truncate()) + }) + .inner; + ui.label_inputs_by(label.id, |ui| add(ui, label.id)); }); } diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index a43ec6677..9ef8f3ed7 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -2,6 +2,24 @@ use super::*; use crate::Services; use vectorcraft_engine::Session; +/// The three marquee choices are available in Preferences and saved through the same path as +/// other selection preferences. +#[test] +fn marquee_selection_choices_are_saved_from_preferences() { + let mut app = app(); + open(&mut app, Some("Selection & Anchor Display")); + let text = crate::tests_labels::painted_text(&mut app, |a, ui| show(a, ui.ctx())); + assert!(text.contains("Marquee Selection") && text.contains("Touching"), "{text}"); + app.ui.dialog.as_mut().unwrap().fields.insert("marqueeSelection".into(), json!("directional")); + confirm(&mut app).unwrap(); + assert_eq!(app.session.prefs.marquee_selection, "directional"); + let saved: Prefs = serde_json::from_value(app.ui.engine_prefs.clone()).unwrap(); + assert_eq!(saved.marquee_selection, "directional"); + open(&mut app, Some("Selection & Anchor Display")); + let text = crate::tests_labels::painted_text(&mut app, |a, ui| show(a, ui.ctx())); + assert!(text.contains("Drag Direction"), "{text}"); +} + #[test] fn geometric_snapping_is_a_saved_smart_guides_preference() { let mut a = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); @@ -16,6 +34,27 @@ fn geometric_snapping_is_a_saved_smart_guides_preference() { assert!(a.run("view.snapping", json!({})).is_err()); } +/// Type › Start Lists From Typed Markers is a checkbox in the Type panel, on by default; turning +/// it off is saved, and every bundled language translates its label. +#[test] +fn start_lists_from_typed_markers_is_a_saved_type_preference() { + const LABEL: &str = "Start Lists From Typed Markers"; + let mut a = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + assert!(a.session.prefs.typed_lists); + open(&mut a, Some("Type")); + let text = crate::tests_labels::painted_text(&mut a, |a, ui| show(a, ui.ctx())); + assert!(text.contains(LABEL), "{text}"); + assert_eq!(a.ui.dialog.as_ref().unwrap().fields.get("typedLists"), Some(&json!(true))); + a.ui.dialog.as_mut().unwrap().fields.insert("typedLists".into(), json!(false)); + confirm(&mut a).unwrap(); + assert!(!a.session.prefs.typed_lists); + let saved: Prefs = serde_json::from_value(a.ui.engine_prefs.clone()).unwrap(); + assert!(!saved.typed_lists); + for lang in crate::i18n::Lang::all().filter(|l| l.code() != crate::i18n::Lang::EN.code()) { + assert!(crate::i18n::has(lang, LABEL), "{}", lang.code()); + } +} + /// A resting checkbox must have a visible outline in both states and every brightness. #[test] fn preference_checkboxes_draw_outlines_without_hover() { @@ -262,6 +301,134 @@ fn system_title_bar_preference_shows_and_persists() { assert_eq!(saved["engine_prefs"]["systemTitleBar"], json!(true)); } +/// User Interface › VectorCraft Title Bar on Every Desktop: shown on Linux and the BSDs only, under +/// System Title Bar with the next-launch note once, and saved under the key the desktop app reads +/// before the window opens. +#[test] +fn app_title_bar_preference_shows_and_persists() { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut a = app(); + open(&mut a, Some("User Interface")); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut shown = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); + let supported = cfg!(not(any(target_arch = "wasm32", target_os = "macos", target_os = "windows"))); + assert_eq!(shown.iter().any(|t| t == "VectorCraft Title Bar on Every Desktop"), supported, "{shown:?}"); + let notes = shown.iter().filter(|t| *t == "Applies the next time VectorCraft starts.").count(); + assert_eq!(notes, usize::from(cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos")))), "{shown:?}"); + a.ui.dialog.as_mut().unwrap().fields.insert("appTitleBar".into(), json!(true)); + confirm(&mut a).unwrap(); + assert!(a.session.prefs.app_title_bar); + let saved: Value = serde_json::from_slice(&serde_json::to_vec(&a.ui).unwrap()).unwrap(); + assert_eq!(saved["engine_prefs"]["appTitleBar"], json!(true)); +} + +/// VectorCraft Title Bar on Every Desktop does not take clicks while System Title Bar is on or +/// VECTORCRAFT_TITLEBAR is set. With the variable set its note names the variable in place of the +/// next-launch note, and with System Title Bar on and the variable unset a note says that System +/// Title Bar takes precedence. +#[test] +fn app_title_bar_is_dimmed_while_something_takes_precedence() { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let note = "The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set."; + let system_note = "System Title Bar takes precedence over this option while it is on."; + let restart_note = "Applies the next time VectorCraft starts."; + let supported = cfg!(not(any(target_arch = "wasm32", target_os = "macos", target_os = "windows"))); + for (from_env, system) in [(false, false), (true, false), (false, true), (true, true)] { + let mut a = app(); + a.titlebar_from_env = from_env; + open(&mut a, Some("User Interface")); + a.ui.dialog.as_mut().unwrap().fields.insert("systemTitleBar".into(), json!(system)); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut shown = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); + assert_eq!(shown.iter().any(|t| t == note), supported && from_env, "{shown:?}"); + assert_eq!(shown.iter().any(|t| t == system_note), supported && system && !from_env, "{shown:?}"); + // The next-launch note stays under this row on Linux and the BSDs, where it also covers + // System Title Bar, and under System Title Bar on Windows. + let restart_shown = if supported { !from_env } else { cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) }; + assert_eq!(shown.iter().filter(|t| *t == restart_note).count(), usize::from(restart_shown), "{shown:?}"); + // A click on the checkbox's label turns the preference on only when nothing takes precedence. + let label = out.shapes.iter().find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text() == "VectorCraft Title Bar on Every Desktop" => Some(t.visual_bounding_rect()), + _ => None, + }); + assert_eq!(label.is_some(), supported); + if let Some(r) = label { + for pressed in [true, false] { + let input = egui::RawInput { + events: vec![ + egui::Event::PointerMoved(r.center()), + egui::Event::PointerButton { pos: r.center(), button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }, + ], + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + } + assert_eq!(a.ui.dialog.as_ref().unwrap().bool("appTitleBar"), !from_env && !system, "variable {from_env}, System Title Bar {system}"); + } + } +} + +/// While VECTORCRAFT_TITLEBAR chooses the frame, System Title Bar does not take clicks. +#[test] +fn system_title_bar_is_dimmed_while_the_environment_variable_chooses_the_frame() { + let supported = cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))); + for from_env in [false, true] { + let mut a = app(); + a.titlebar_from_env = from_env; + open(&mut a, Some("User Interface")); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let label = out.shapes.iter().find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text() == "System Title Bar" => Some(t.visual_bounding_rect()), + _ => None, + }); + assert_eq!(label.is_some(), supported); + if let Some(r) = label { + for pressed in [true, false] { + let input = egui::RawInput { + events: vec![ + egui::Event::PointerMoved(r.center()), + egui::Event::PointerButton { pos: r.center(), button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }, + ], + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + } + assert_eq!(a.ui.dialog.as_ref().unwrap().bool("systemTitleBar"), !from_env, "clicked with the variable {from_env}"); + } + } +} + /// Performance › Linux Display Server (#2028): on Linux it shows the drawing-tablet note, and /// X11 (XWayland) When a Pen Is Attached is applied by OK and saved under the key the desktop /// app reads before the window opens. @@ -503,6 +670,41 @@ fn numbers_without_units_are_points_reaches_the_fields_and_dims_without_picas() assert!(picas_in_use(d), "enabled with a unit in picas"); } +/// General › Scroll Sideways with Cmd/Ctrl + Wheel: its checkbox is dimmed while Zoom with +/// Mouse Wheel is checked in the dialog, and a click on it then leaves the setting as it was. +#[test] +fn scroll_sideways_with_cmd_wheel_dims_with_zoom_with_mouse_wheel() { + fn label(s: &egui::Shape, text: &str) -> Option<(egui::Color32, egui::Rect)> { + match s { + egui::Shape::Text(t) if t.galley.text() == text => t.galley.job.sections.first().map(|s| (s.format.color, t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().find_map(|s| label(s, text)), + _ => None, + } + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut a = app(); + let mut frame = |d: &mut Dialog, events: Vec| { + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 900.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| category_fields(ui, &mut a, d, "General")); + out.textures_delta.clear(); + out.shapes.iter().find_map(|c| label(&c.shape, "Scroll Sideways with Cmd/Ctrl + Wheel")) + }; + let t = Tokens::get(&ctx); + for (zoom, color, what) in [(false, t.text, "enabled while Zoom with Mouse Wheel is off"), (true, t.text_disabled, "dimmed while it is on")] { + let mut d = Dialog::new("preferences", json!({"zoomWithMouseWheel": zoom, "ctrlWheelScrollsSideways": true})); + let (shown, at) = frame(&mut d, vec![]).unwrap_or_else(|| panic!("no label ({what})")); + assert_eq!(shown, color, "{what}"); + let press = + |pressed| egui::Event::PointerButton { pos: at.center(), button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut d, vec![egui::Event::PointerMoved(at.center()), press(true)]); + frame(&mut d, vec![press(false)]); + // A click turns it off while it is enabled, and leaves it on while it is dimmed. + assert_eq!(d.fields["ctrlWheelScrollsSideways"], json!(zoom), "after a click, {what}"); + } +} + /// 0.5.0 saved `powerSaving` for everyone (its default): it reads as Automatic, while the /// choices this version offers are kept (#502). #[test] @@ -790,6 +992,95 @@ fn ok_refuses_a_typed_folder_that_choose_would_refuse() { let _ = std::fs::remove_dir_all(&dir); } +/// Preferences form rows associate each input with its visible label for accessibility tools. +#[test] +fn preference_inputs_expose_their_visible_labels() { + use egui_kittest::kittest::Queryable; + let mut app = app(); + open(&mut app, Some("General")); + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 700.0)).with_accessibility_check(false).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Keyboard Increment:"); +} + +/// Render the real category without the dialog's scroll viewport clipping later rows. +fn preference_category_harness(category: &'static str) -> egui_kittest::Harness<'static, (VectorcraftApp, Dialog)> { + let mut app = app(); + app.services.pick_folder = Some(Box::new(|| None)); + open(&mut app, Some(category)); + let dialog = app.ui.dialog.take().unwrap(); + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 1400.0)).with_accessibility_check(false).build_ui_state( + move |ui, (app, dialog): &mut (VectorcraftApp, Dialog)| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + category_fields(ui, app, dialog, category); + }, + (app, dialog), + ); + h.run_steps(3); + h +} + +#[test] +fn preference_colour_well_exposes_its_row_label_and_value() { + use egui_kittest::kittest::{NodeT, Queryable}; + let h = preference_category_harness("Guides & Grid"); + let expected = format!("{}ff", h.state().1.str("patternTileEdgeColor").to_lowercase()); + let colour = h.get_by_role_and_label(egui::accesskit::Role::ColorWell, "Tile Edge Color:"); + assert_eq!(colour.accesskit_node().value().as_deref(), Some(expected.as_str())); +} + +#[test] +fn preference_scale_labels_both_controls_across_focus_changes() { + use egui::accesskit::Role; + use egui_kittest::kittest::{NodeT, Queryable}; + let mut h = preference_category_harness("User Interface"); + let expected = h.state().1.f64("uiScaling", 1.0); + for focus in [None, Some(Role::SpinButton), Some(Role::Slider)] { + if let Some(role) = focus { + h.get_by_role_and_label(role, "Scale:").focus(); + h.run_steps(3); + assert!(h.get_by_role_and_label(role, "Scale:").accesskit_node().is_focused()); + } + for role in [Role::Slider, Role::SpinButton] { + let control = h.get_by_role_and_label(role, "Scale:"); + assert_eq!(control.accesskit_node().numeric_value(), Some(expected)); + } + } +} + +#[test] +fn preference_anchor_slider_exposes_its_row_label_and_value() { + use egui::accesskit::Role; + use egui_kittest::kittest::{NodeT, Queryable}; + let h = preference_category_harness("Selection & Anchor Display"); + let slider = h.get_by_role_and_label(Role::Slider, "Size:"); + assert_eq!(slider.accesskit_node().numeric_value(), Some(h.state().1.f64("anchorSize", 3.0))); +} + +#[test] +fn preference_folder_label_preserves_the_choose_action_name() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + let h = preference_category_harness("File Handling"); + let _ = h.get_by_role_and_label(Role::TextInput, "Folder:"); + let _ = h.get_by_role_and_label(Role::Button, "Choose…"); + assert!(h.query_by_role_and_label(Role::Button, "Folder:").is_none()); +} + /// Under automation roots, OK refuses a typed folder outside them before it looks at the folder. #[test] fn ok_refuses_a_typed_folder_outside_the_automation_roots_before_looking_at_it() { diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index 5287a4b6a..443d4aad3 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -28,10 +28,11 @@ pub struct Timer { beat: f64, } -/// Each frame: the startup question once, the timer and the heartbeat (`now`: app time in -/// seconds) → in how many seconds they want the next frame (none while Data Recovery is off). +/// Each frame: the startup question once no other dialog is open, the timer and the heartbeat +/// (`now`: app time in seconds) → in how many seconds they want the next frame (none while Data Recovery is off). pub fn frame(app: &mut VectorcraftApp, now: f64) -> Option { - if !app.recovery.checked { + // A dialog already open at launch (the Safe Mode choice) is answered first. + if !app.recovery.checked && app.ui.dialog.is_none() { app.recovery.checked = true; offer(app); } diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index d5ed1ade2..b17a4c47a 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -66,13 +66,17 @@ pub(crate) use ui_mirror; pub(crate) struct Mirror { pub keymap: Keymap, text: HashMap>, + /// The overrides this was built from, as the UI state holds them. + source: Overrides, + /// The plug-in revision of the entries the keymap holds ([`Snapshot::revision`]). + revision: u64, } impl Mirror { fn new(overrides: Overrides) -> Mirror { - let keymap = crate::keymap::template().clone().with_overrides(overrides); - let text = keymap - .overrides() + let snapshot = snapshot(); + let keymap = crate::keymap::keymap_in(&snapshot, &overrides); + let text = overrides .iter() .map(|(k, _)| { let key = entry_of_stored(k); @@ -80,20 +84,41 @@ impl Mirror { (key, t) }) .collect(); - Mirror { keymap, text } + Mirror { keymap, text, source: overrides, revision: snapshot.revision } + } + + /// Was this built from `overrides` for the platform and the plug-ins in effect now? + fn is_for(&self, overrides: &Overrides) -> bool { + self.source == *overrides && self.revision == menus::plugin_revision() && self.keymap.platform() == crate::keymap::platform() } } ui_mirror!(fn store() -> Option>); -/// The mirror: the keymap in effect. +/// The mirror: the keymap in effect. One the plug-ins installed or removed since it was built +/// left behind is built again, with the overrides it was built from. pub(crate) fn current() -> Arc { - static DEFAULTS: [OnceLock>; 3] = [OnceLock::new(), OnceLock::new(), OnceLock::new()]; - store() - .read() - .ok() - .and_then(|m| m.clone()) - .unwrap_or_else(|| crate::keymap::per_platform(&DEFAULTS, crate::keymap::platform(), || Arc::new(Mirror::new(Overrides::new()))).clone()) + static DEFAULT: Mutex>> = Mutex::new(None); + if let Some(m) = store().read().ok().and_then(|m| m.clone()) { + if m.is_for(&m.source) { + return m; + } + let fresh = Arc::new(Mirror::new(m.source.clone())); + if let Ok(mut s) = store().write() + && s.as_ref().is_some_and(|s| Arc::ptr_eq(s, &m)) + { + *s = Some(fresh.clone()); + } + return fresh; + } + let none = Overrides::new(); + let cached = DEFAULT.lock().unwrap_or_else(|e| e.into_inner()).clone(); + if let Some(m) = cached.filter(|m| m.is_for(&none)) { + return m; + } + let fresh = Arc::new(Mirror::new(none)); + *DEFAULT.lock().unwrap_or_else(|e| e.into_inner()) = Some(fresh.clone()); + fresh } /// Leak-once interning for override strings (bounded by the chords a user ever assigns). @@ -113,7 +138,7 @@ pub fn sync(ui: &UiState) { let same = store() .read() .map(|m| match &*m { - Some(m) => *m.keymap.overrides() == ui.shortcut_overrides && m.keymap.platform() == crate::keymap::platform(), + Some(m) => m.is_for(&ui.shortcut_overrides), None => ui.shortcut_overrides.is_empty(), }) .unwrap_or(false); @@ -210,6 +235,7 @@ pub fn command_for_key(key: &str) -> Option<&'static str> { let c = crate::keymap::lookup(¤t().keymap, &vc_chord(key)?)?; vectorcraft_engine::command_specs() .iter() + .chain(vectorcraft_engine::cmd::plugin::command_specs()) .map(|c| c.id) .chain(menus::UI_COMMANDS.iter().map(|c| c.0)) .find(|id| *id == c.id && c.params.is_null()) @@ -334,9 +360,60 @@ pub struct Entry { pub is_tool: bool, } +/// The editor's entries, the Effect menu's effect items and Object › Plug-ins' filter items while +/// the installed plug-ins are those of one [`menus::plugin_revision`]. +pub(crate) struct Snapshot { + /// The plug-in revision read before the menus were walked. + pub revision: u64, + pub effects: Arc<[EffectItem]>, + pub filters: Arc<[FilterItem]>, + pub entries: Arc<[Entry]>, +} + +/// The entries for the plug-ins installed now: rebuilt when a plug-in is installed or removed, so +/// a live-effect or filter plug-in installed later has its row, and one removed has none. +pub(crate) fn snapshot() -> Arc { + static LAST: Mutex>> = Mutex::new(None); + let revision = menus::plugin_revision(); + let last = LAST.lock().unwrap_or_else(|e| e.into_inner()).clone(); + if let Some(s) = last.filter(|s| s.revision == revision) { + return s; + } + // Built without the lock held: the Effect menu is walked anew. + let effects: Arc<[EffectItem]> = build_effect_items().into(); + let filters: Arc<[FilterItem]> = build_filter_items().into(); + let (head, tail) = fixed_entries(); + let entries: Arc<[Entry]> = head + .iter() + .cloned() + .chain(effects.iter().map(|e| Entry { key: format!("effect:{}", e.effect), label: e.label.clone(), group: e.group.clone(), is_tool: false })) + .chain(filters.iter().map(|f| Entry { key: format!("plugin:{}", f.id), label: f.label.clone(), group: f.group.clone(), is_tool: false })) + .chain(vectorcraft_engine::cmd::plugin::command_specs().into_iter().map(|c| Entry { + key: c.id.into(), + label: c.label.into(), + group: c.menu.join(" › "), + is_tool: false, + })) + .chain(tail.iter().cloned()) + .collect(); + let s = Arc::new(Snapshot { revision, effects, filters, entries }); + let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner()); + // A newer snapshot another thread stored meanwhile stays. + if last.as_ref().is_none_or(|l| l.revision <= revision) { + *last = Some(s.clone()); + } + s +} + /// Every tool, command and panel the editor can give a shortcut; [`listed`] says which it lists. -pub fn entries() -> &'static [Entry] { - static E: OnceLock> = OnceLock::new(); +pub fn entries() -> Arc<[Entry]> { + snapshot().entries.clone() +} + +/// The entries no plug-in changes: (tools, commands and panels; UI commands). The Effect menu's +/// items, Object › Plug-ins' filters and the plug-in commands go between them. +fn fixed_entries() -> &'static (Vec, Vec) { + static E: OnceLock<(Vec, Vec)> = OnceLock::new(); E.get_or_init(|| { let mut v: Vec = vectorcraft_tools::catalog::all_tools() .map(|t| Entry { key: format!("tool:{}", t.id), label: t.label.to_string(), group: "Tools".into(), is_tool: true }) @@ -362,22 +439,17 @@ pub fn entries() -> &'static [Entry] { group: "Window".into(), is_tool: false, })); - // Each effect of the Effect menu, by its own key: they all run `effect.dialog` (#3296). - v.extend(effect_items().iter().map(|e| Entry { - key: format!("effect:{}", e.effect), - label: e.label.clone(), - group: e.group.clone(), - is_tool: false, - })); + // Each effect of the Effect menu comes next, by its own key (#3296): see [`snapshot`]. + let mut tail = vec![]; for (id, label, sc, params) in menus::UI_COMMANDS { // Rename has no default key, and a key press names the selection: it needs no `id`. let key_runs_it = !sc.is_empty() || params.starts_with("{}") || params.starts_with("{path") || *id == "ui.rename"; if !seen.insert(id) || !key_runs_it { continue; } - v.push(Entry { key: id.to_string(), label: label.to_string(), group: id_group(id), is_tool: false }); + tail.push(Entry { key: id.to_string(), label: label.to_string(), group: id_group(id), is_tool: false }); } - v + (v, tail) }) } @@ -391,38 +463,41 @@ pub struct EffectItem { pub group: String, } -/// The Effect menu's effect items, in menu order, each once. -pub fn effect_items() -> &'static [EffectItem] { - static E: OnceLock> = OnceLock::new(); - E.get_or_init(|| { - fn walk(items: &[menus::Item], path: &mut Vec, seen: &mut HashSet, out: &mut Vec) { - for it in items { - match it { - menus::Item::Cmd(label, id @ ("effect.dialog" | "effect.apply"), p) => { - if let Some(effect) = p.get("effect").and_then(serde_json::Value::as_str) - && seen.insert(effect.to_string()) - { - out.push(EffectItem { effect: effect.to_string(), command: id, label: label.to_string(), group: path.join(" › ") }); - } +/// The Effect menu's effect items, in menu order, each once, with the live-effect plug-ins +/// installed now. +pub fn effect_items() -> Arc<[EffectItem]> { + snapshot().effects.clone() +} + +/// Walks the Effect menu for [`effect_items`]. +fn build_effect_items() -> Vec { + fn walk(items: &[menus::Item], path: &mut Vec, seen: &mut HashSet, out: &mut Vec) { + for it in items { + match it { + menus::Item::Cmd(label, id @ ("effect.dialog" | "effect.apply"), p) => { + if let Some(effect) = p.get("effect").and_then(serde_json::Value::as_str) + && seen.insert(effect.to_string()) + { + out.push(EffectItem { effect: effect.to_string(), command: id, label: label.to_string(), group: path.join(" › ") }); } - menus::Item::Sub(name, children) => { - path.push(name.to_string()); - walk(children, path, seen, out); - path.pop(); - } - _ => {} } + menus::Item::Sub(name, children) => { + path.push(name.to_string()); + walk(children, path, seen, out); + path.pop(); + } + _ => {} } } - let mut out = vec![]; - let mut seen = HashSet::new(); - for (name, items) in menus::menu_tree() { - if name == "Effect" { - walk(&items, &mut vec![name.to_string()], &mut seen, &mut out); - } + } + let mut out = vec![]; + let mut seen = HashSet::new(); + for (name, items) in menus::menu_tree() { + if name == "Effect" { + walk(&items, &mut vec![name.to_string()], &mut seen, &mut out); } - out - }) + } + out } /// Effective shortcut of the Effect menu item of `effect` (`effect:`). @@ -430,6 +505,74 @@ pub fn effect_shortcut(effect: &str) -> Option<&'static str> { effective(&format!("effect:{effect}")) } +/// One filter plug-in's Object › Plug-ins item (`plugin.dialog {id}`) a shortcut can be given: the +/// plug-in's id, its label (the plug-in's name, with an ellipsis when it opens a dialog) and its +/// menu path. +#[derive(Clone, Debug)] +pub struct FilterItem { + pub id: String, + pub label: String, + pub group: String, +} + +/// Object › Plug-ins' filter items, in menu order, each once, with the filter plug-ins installed +/// now. +pub fn filter_items() -> Arc<[FilterItem]> { + snapshot().filters.clone() +} + +/// Walks the Object menu for [`filter_items`]. +fn build_filter_items() -> Vec { + fn walk(items: &[menus::Item], path: &mut Vec, seen: &mut HashSet, out: &mut Vec) { + for it in items { + match it { + menus::Item::Cmd(label, "plugin.dialog", p) => { + if let Some(id) = p.get("id").and_then(serde_json::Value::as_str) + && seen.insert(id.to_string()) + { + out.push(FilterItem { id: id.to_string(), label: label.to_string(), group: path.join(" › ") }); + } + } + menus::Item::Sub(name, children) => { + path.push(name.to_string()); + walk(children, path, seen, out); + path.pop(); + } + _ => {} + } + } + } + let mut out = vec![]; + let mut seen = HashSet::new(); + for (name, items) in menus::menu_tree() { + if name == "Object" { + walk(&items, &mut vec![name.to_string()], &mut seen, &mut out); + } + } + out +} + +/// Effective shortcut of filter plug-in `id`'s Object › Plug-ins item (`plugin:`). +pub fn filter_shortcut(id: &str) -> Option<&'static str> { + effective(&format!("plugin:{id}")) +} + +/// Entry `e`'s label in the UI language. A plug-in's own name is not translated. +pub(crate) fn shown_label(e: &Entry) -> &str { + let plugin = e.key.starts_with("plugin:") || e.key.strip_prefix("effect:").and_then(vectorcraft_plugins::effect::plugin_id).is_some(); + if plugin { &e.label } else { tl!(&e.label) } +} + +/// A menu path in the UI language, one segment at a time. +pub(crate) fn shown_group(group: &str) -> String { + group.split(" › ").map(|s| tl!(s)).collect::>().join(" › ") +} + +/// How Keyboard Shortcuts names entry `e`: a tool by its label, the others after their menu. +pub(crate) fn shown_name(e: &Entry) -> String { + if e.is_tool || e.group.is_empty() { shown_label(e).to_string() } else { format!("{} › {}", shown_group(&e.group), shown_label(e)) } +} + /// The group of a command no menu holds: the first part of its id, capitalised (`edit.x` → "Edit"). fn id_group(id: &str) -> String { let mut c = id.split('.').next().unwrap_or_default().chars(); @@ -447,14 +590,15 @@ fn runs_without_params(params: &str) -> bool { /// Does the editor list `e` under `overrides`? An entry that [`needs_a_value`] is listed only while /// the user has given it a shortcut. fn listed(overrides: &Overrides, e: &Entry) -> bool { - !needs_a_value(&e.key) || overrides.get(&crate::keymap::stored_key(&e.key)).is_some_and(|t| !t.is_empty()) + !needs_a_value(&e.key) || crate::keymap::stored_texts(overrides, &e.key).is_some_and(|t| !t.is_empty()) } /// Commands whose params are all optional that do nothing, or fail, when a key press runs them /// with none, and that no item of the menu bar runs: Transparency and Opacity Mask Options need /// a value to set, Align and Distribute a direction, Delete Swatch and Move Swatch a swatch name, -/// and the library, data set and text style loaders a file, which their panel menu items ask for. -const NEEDS_A_PARAM: [&str; 19] = [ +/// and the library, data set, text style and SVG filter loaders a file, which their menu items ask +/// for. +const NEEDS_A_PARAM: [&str; 20] = [ "transparency.set", "transparency.setOpacityMask", "stroke.set", @@ -474,6 +618,7 @@ const NEEDS_A_PARAM: [&str; 19] = [ "charStyle.load", "paraStyle.load", "textStyle.loadAll", + "svgFilter.import", ]; /// The (label, params) of each menu bar item, by command id. @@ -539,6 +684,17 @@ fn needs_a_value(key: &str) -> bool { .contains(key) } +/// Does a menu bar item run the command with no params, so that it does what its label says and opens +/// no dialog? A command no menu bar item runs (the Crop Image item runs `ui.cropImage`), a menu item +/// that gives it params, a [`NEEDS_A_PARAM`] command and a field a call must give each rule it out. +pub(crate) fn runs_with_no_values(c: &vectorcraft_engine::CommandSpec) -> bool { + menu_bar_items().contains_key(c.id) + && menu_target(c.id).is_none() + && !NEEDS_A_PARAM.contains(&c.id) + && !crate::dialogs::variables::opens(c.id) + && !names_a_required_field(c.params) +} + /// Does a params doc name a field a call must give? Its fields are the top-level parts of its /// first `{…}`. A field is optional when it is empty, starts with `…`, gives a default (`rows=10`) /// or each of its names ends with `?` (`id?|ids?: [id…]`); `{index | indices}` names a required @@ -570,17 +726,19 @@ fn names_a_required_field(params: &str) -> bool { false } -pub fn entry(key: &str) -> Option<&'static Entry> { - entries().iter().find(|e| e.key == key) +pub fn entry(key: &str) -> Option { + entries().iter().find(|e| e.key == key).cloned() } /// Entries (other than `except`) whose effective shortcut equals `chord`. pub fn conflicts_with(overrides: &Overrides, chord: &str, except: &str) -> Vec { let Some(c) = vc_chord(chord) else { return vec![] }; - let km = keymap_with(overrides); + let snapshot = snapshot(); + let km = crate::keymap::keymap_in(&snapshot, overrides); let p = km.platform(); let want = Trigger::single(c).effective(p); - entries() + snapshot + .entries .iter() .filter(|e| e.key != except) .filter(|e| km.bindings(&command_of(&e.key)).iter().any(|b| b.trigger.effective(p) == want)) @@ -612,10 +770,32 @@ pub fn conflicts_in(km: &Keymap) -> Vec<(String, Vec)> { pub fn assign(overrides: &mut Overrides, key: &str, chord: Option<&str>, force: bool) -> Result, String> { let mut km = keymap_with(overrides); let removed = assign_in(&mut km, key, chord, force)?; - *overrides = km.overrides().clone(); + *overrides = saved_overrides(km.overrides(), overrides); Ok(removed) } +/// The overrides to save from a working keymap's `current` overrides. An entry for Cut, Copy or +/// Paste that `source`, the set before the change, did not hold is left out when it is the entry +/// [`crate::keymap::keymap_with`] makes from the other entries of `source` or of `current`. +pub(crate) fn saved_overrides(current: &Overrides, source: &Overrides) -> Overrides { + let mut saved = current.clone(); + for (key, _, _) in crate::keymap::EDIT_FUNCTION_KEYS { + let Some(now) = current.get(key) else { continue }; + if source.get(key).is_some() { + continue; + } + let made = |from: &Overrides| { + let mut rest = from.clone(); + rest.remove(key); + crate::keymap::edit_alias_overrides(&rest, crate::keymap::platform()).get(key).is_some_and(|t| t == now) + }; + if made(current) || made(source) { + saved.remove(key); + } + } + saved +} + /// [`assign`] on a keymap. pub fn assign_in(km: &mut Keymap, key: &str, chord: Option<&str>, force: bool) -> Result, String> { let command = command_of(key); @@ -664,7 +844,7 @@ pub fn assign_in(km: &mut Keymap, key: &str, chord: Option<&str>, force: bool) - } pub fn reset_one(overrides: &mut Overrides, key: &str) { - overrides.remove(&crate::keymap::stored_key(key)); + crate::keymap::remove_override(overrides, key); } /// Text shown for all active chords in a dialog row, with the backtick key shown as `typed` @@ -707,14 +887,15 @@ pub fn import_json(v: &Value) -> Result<(String, Overrides), String> { /// row per line with the name and the shortcuts as the dialog shows them, separated by a tab. A /// row with no shortcut ends after the tab. pub fn export_text(set: &str, overrides: &Overrides) -> String { - let km = keymap_with(overrides); + let snapshot = snapshot(); + let km = crate::keymap::keymap_in(&snapshot, overrides); let backtick = backtick_text(); let shown_set = if PRESETS.contains(&set) || set == CUSTOM { tl!(set) } else { set }; let mut out = format!("{}\n{} {shown_set}\n", tl!("Keyboard Shortcuts"), tl!("Set:")); for (tools, heading) in [(true, "Tools"), (false, "Menu Commands")] { out += &format!("\n{}\n", tl!(heading)); - for e in entries().iter().filter(|e| e.is_tool == tools && listed(km.overrides(), e)) { - let name = if e.is_tool || e.group.is_empty() { tl!(&e.label).to_string() } else { format!("{} › {}", tl!(&e.group), tl!(&e.label)) }; + for e in snapshot.entries.iter().filter(|e| e.is_tool == tools && listed(km.overrides(), e)) { + let name = shown_name(e); let sc = dialog_shortcuts_with(&km, &e.key, backtick.as_deref()).unwrap_or_default(); out += &format!("{name}\t{sc}\n"); } @@ -788,10 +969,12 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option q.unwrap_or_default().to_lowercase(), Err(e) => return Some(Err(e)), }; - let km = keymap_with(&app.ui.shortcut_overrides); - let ov = km.overrides(); + let snapshot = snapshot(); + let km = crate::keymap::keymap_in(&snapshot, &app.ui.shortcut_overrides); + let ov = &crate::keymap::effect_keys_in(&app.ui.shortcut_overrides, &snapshot.effects); Ok(Value::Array( - entries() + snapshot + .entries .iter() .filter(|e| listed(ov, e)) .filter(|e| q.is_empty() || e.label.to_lowercase().contains(&q) || e.key.to_lowercase().contains(&q)) @@ -925,13 +1108,17 @@ pub fn is_recording(app: &VectorcraftApp) -> bool { // ---------- dialog ---------- -/// The dialog's working set as a keymap, with each entry's shortcut text (in [`entries`] order) -/// and the number of conflicts, kept between frames while the set is unchanged. +/// The dialog's working set as a keymap, with the entries it lists, each entry's shortcut text (in +/// their order) and the number of conflicts, kept between frames while the set and the installed +/// plug-ins are unchanged. struct DialogView { /// The dialog's `overrides` field this was built from. source: Value, /// The [`backtick_text`] the texts show. backtick: Option, + /// The plug-in revision of `entries`. + revision: u64, + entries: Arc<[Entry]>, keymap: Keymap, texts: Vec>, conflicts: usize, @@ -939,20 +1126,32 @@ struct DialogView { impl DialogView { fn new(overrides: Overrides) -> DialogView { - let keymap = keymap_with(&overrides); + let snapshot = snapshot(); + let keymap = crate::keymap::keymap_in(&snapshot, &overrides); let backtick = backtick_text(); - let texts = entries().iter().map(|e| dialog_shortcuts_with(&keymap, &e.key, backtick.as_deref())).collect(); + let texts = snapshot.entries.iter().map(|e| dialog_shortcuts_with(&keymap, &e.key, backtick.as_deref())).collect(); let conflicts = conflicts_in(&keymap).len(); - DialogView { source: stored::to_value(keymap.overrides()), backtick, keymap, texts, conflicts } + DialogView { + source: stored::to_value(&crate::keymap::effect_keys_in(&overrides, &snapshot.effects)), + backtick, + revision: snapshot.revision, + entries: snapshot.entries.clone(), + keymap, + texts, + conflicts, + } } - /// The view of `d`'s working set, from the cache unless the set or the backtick key's - /// character changed. The dialog's `overrides` field is left in its stored form. + /// The view of `d`'s working set, from the cache unless the set, the backtick key's character + /// or the installed plug-ins changed. The dialog's `overrides` field is left in its stored form. fn of(ctx: &egui::Context, d: &mut Dialog) -> Arc { let id = egui::Id::unique("dialog-shortcuts-view"); let source = d.fields.get("overrides").unwrap_or(&Value::Null); let backtick = backtick_text(); - if let Some(v) = ctx.data(|m| m.get_temp::>(id)).filter(|v| v.source == *source && v.backtick == backtick) { + let revision = menus::plugin_revision(); + if let Some(v) = + ctx.data(|m| m.get_temp::>(id)).filter(|v| v.source == *source && v.backtick == backtick && v.revision == revision) + { return v; } let v = Arc::new(DialogView::new(dialog_overrides(d))); @@ -972,6 +1171,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let mut ok = false; let mut cancel = false; drop_hidden(&mut d, km.overrides()); + let saved = dialog_overrides(&d); let recording = d.str("__recording"); // Record a chord (before widgets see the keys). Modifiers held down are part of the next key's // chord, not a chord of their own. @@ -1060,25 +1260,21 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(tl!("Shortcut")).color(t.text_dim).size(11.0)); }); egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, false]).show(ui, |ui| { - for (i, e) in entries().iter().enumerate().filter(|(_, e)| e.is_tool == tools && listed(km.overrides(), e)) { + for (i, e) in view.entries.iter().enumerate().filter(|(_, e)| e.is_tool == tools && listed(km.overrides(), e)) { let sc = match &km { Cow::Borrowed(_) => view.texts.get(i).cloned().flatten(), Cow::Owned(km) => dialog_shortcuts_with(km, &e.key, view.backtick.as_deref()), }; let matches = e.label.to_lowercase().contains(&q) - || tl!(&e.label).to_lowercase().contains(&q) + || shown_label(e).to_lowercase().contains(&q) || e.group.to_lowercase().contains(&q) - || tl!(&e.group).to_lowercase().contains(&q) + || shown_group(&e.group).to_lowercase().contains(&q) || sc.as_deref().is_some_and(|s| s.to_lowercase().contains(&q)); if !q.is_empty() && !matches { continue; } let row = ui.horizontal(|ui| { - let name = if e.is_tool || e.group.is_empty() { - tl!(&e.label).to_string() - } else { - format!("{} › {}", tl!(&e.group), tl!(&e.label)) - }; + let name = shown_name(e); let r = ui .allocate_ui_with_layout(egui::vec2(360.0, 20.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { ui.set_min_width(360.0); @@ -1089,7 +1285,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { d.fields.insert("__selected".into(), json!(e.key)); } let label = if rec == e.key { tl!("Press keys…").to_string() } else { sc.unwrap_or_else(|| "—".into()) }; - let changed = crate::keymap::has_override(km.overrides(), &e.key); + let changed = crate::keymap::has_override(&saved, &e.key); let txt = egui::RichText::new(label).color(if changed { t.accent } else { t.text }); let b = ui .add_sized([150.0, 20.0], egui::Button::new(txt).selected(rec == e.key)) @@ -1161,7 +1357,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { }); }); if let Cow::Owned(km) = &km { - d.fields.insert("overrides".into(), stored::to_value(km.overrides())); + d.fields.insert("overrides".into(), stored::to_value(&saved_overrides(km.overrides(), &saved))); } let import = d.fields.remove("__import").is_some(); let export = d.fields.remove("__export").is_some(); @@ -1246,7 +1442,7 @@ fn export_dialog_set(app: &mut VectorcraftApp, command: &str) { fn drop_hidden(d: &mut Dialog, overrides: &Overrides) { for field in ["__recording", "__selected", "__conflict"] { let key = d.str(field); - if !key.is_empty() && entry(&key).is_none_or(|e| !listed(overrides, e)) { + if !key.is_empty() && entry(&key).is_none_or(|e| !listed(overrides, &e)) { d.fields.insert(field.into(), json!("")); } } @@ -1286,9 +1482,23 @@ fn record(d: &mut Dialog, km: &mut Keymap, key: &str, chord: &str) { mod tests { use super::*; + /// Runs `f` again until no plug-in was installed or removed while it ran, and returns what it + /// returned. Other tests install and remove plug-ins at any time, and each changes the Effect + /// menu's rows. + fn with_steady_plugins(mut f: impl FnMut() -> T) -> T { + for _ in 0..100 { + let revision = menus::plugin_revision(); + let r = f(); + if menus::plugin_revision() == revision { + return r; + } + } + panic!("the installed plug-ins kept changing"); + } + #[test] fn format_parse_round_trip_for_every_default() { - for e in entries() { + for e in entries().iter() { if let Some(s) = default_of(&e.key) && let Some(sc) = crate::shortcuts::parse(s) { @@ -1392,7 +1602,6 @@ mod tests { open(&mut app); let ctx = egui::Context::default(); theme::install_fonts(&ctx); - let at = entries().iter().position(|e| e.key == "edit.clear").unwrap(); let press = |key| egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: Modifiers::NONE }; let text = |t: &str| egui::Event::Text(t.into()); let mut record_with = |events: Vec| { @@ -1400,7 +1609,9 @@ mod tests { let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(&mut app, ui.ctx())); out.textures_delta.clear(); let d = app.ui.dialog.clone().unwrap(); - let shown = DialogView::new(dialog_overrides(&d)).texts[at].clone(); + let view = DialogView::new(dialog_overrides(&d)); + let at = view.entries.iter().position(|e| e.key == "edit.clear").unwrap(); + let shown = view.texts[at].clone(); (crate::keymap::override_text(&dialog_overrides(&d), "edit.clear"), shown) }; assert_eq!(record_with(vec![press(Key::Backtick), text("\"")]), (Some("~".to_string()), Some("\"".to_string()))); @@ -1554,18 +1765,26 @@ mod tests { fn export_text_lists_the_dialogs_rows_with_their_shortcuts() { let mut ov = Overrides::new(); crate::keymap::set_override(&mut ov, "edit.preferences", "Ctrl+Alt+Shift+9"); - let text = export_text("VectorCraft Defaults", &ov); + let (text, km, tool_rows, menu_rows) = with_steady_plugins(|| { + let text = export_text("VectorCraft Defaults", &ov); + let km = keymap_with(&ov); + let names = |tools: bool| -> Vec { + entries() + .iter() + .filter(|e| e.is_tool == tools && listed(km.overrides(), e)) + .map(|e| if e.is_tool || e.group.is_empty() { format!("{}\t", e.label) } else { format!("{} › {}\t", e.group, e.label) }) + .collect() + }; + let rows = (names(true), names(false)); + (text, km, rows.0, rows.1) + }); let lines: Vec<&str> = text.lines().collect(); assert_eq!(lines[..4], ["Keyboard Shortcuts", "Set: VectorCraft Defaults", "", "Tools"]); let menu_at = lines.iter().position(|l| *l == "Menu Commands").expect("a Menu Commands section"); assert_eq!(lines[menu_at - 1], "", "a blank line before Menu Commands"); - let km = keymap_with(&ov); - let names = |tools: bool| -> Vec { - entries().iter().filter(|e| e.is_tool == tools && listed(km.overrides(), e)).map(|e| text_row(&e.key, "")).collect() - }; let names_of = |rows: &[&str]| -> Vec { rows.iter().map(|l| format!("{}\t", l.split('\t').next().unwrap_or_default())).collect() }; - assert_eq!(names_of(&lines[4..menu_at - 1]), names(true), "the Tools rows, in the dialog's order"); - assert_eq!(names_of(&lines[menu_at + 1..]), names(false), "the Menu Commands rows, in the dialog's order"); + assert_eq!(names_of(&lines[4..menu_at - 1]), tool_rows, "the Tools rows, in the dialog's order"); + assert_eq!(names_of(&lines[menu_at + 1..]), menu_rows, "the Menu Commands rows, in the dialog's order"); let pen = menus::pretty_shortcut(default_of("tool:pen").expect("the Pen tool has a default key")); assert!(lines[4..menu_at].contains(&text_row("tool:pen", &pen).as_str()), "{text}"); let prefs = menus::pretty_shortcut(&effective_with(&km, "edit.preferences").expect("the shortcut set")); @@ -1605,7 +1824,11 @@ mod tests { Ok(()) })); app.run("shortcuts.import", json!({"data": export_json("Studio", &Overrides::new())})).unwrap(); - assert_eq!(app.run("shortcuts.exportText", json!({"path": "/sets/Studio.txt"})), Ok(json!({"path": "/sets/Studio.txt"}))); + let expected = with_steady_plugins(|| { + written.borrow_mut().clear(); + assert_eq!(app.run("shortcuts.exportText", json!({"path": "/sets/Studio.txt"})), Ok(json!({"path": "/sets/Studio.txt"}))); + export_text("Studio", &Overrides::new()) + }); assert_eq!( app.run("shortcuts.exportText", json!({"path": "/missing/dir/Studio.txt"})), Err("can't write /missing/dir/Studio.txt".to_string()) @@ -1615,7 +1838,7 @@ mod tests { let [(path, text)] = files.as_slice() else { panic!("{files:?}") }; assert_eq!(path, "/sets/Studio.txt"); assert!(text.starts_with("Keyboard Shortcuts\nSet: Studio\n\nTools\n"), "{text}"); - assert_eq!(*text, export_text("Studio", &Overrides::new())); + assert_eq!(*text, expected); } /// The dialog's Import… leaves an action its function key when the imported set gives the key @@ -1659,7 +1882,7 @@ mod tests { fn paint_dialog_rows_show_both_default_keys() { let view = DialogView::new(Overrides::new()); for (key, plain, shifted) in [("paint.lastColor", ",", "Shift+Comma"), ("paint.lastGradient", ".", "Shift+Period")] { - let at = entries().iter().position(|e| e.key == key).unwrap(); + let at = view.entries.iter().position(|e| e.key == key).unwrap(); let shown = view.texts[at].as_deref().unwrap(); let shifted = menus::pretty_shortcut(shifted); assert!(shown.contains(plain) && shown.contains(&shifted), "{key}: {shown}"); @@ -1667,6 +1890,126 @@ mod tests { assert_eq!(view.conflicts, 0); } + #[test] + fn edit_dialog_rows_show_primary_and_function_keys() { + for p in [craft_keymap::Platform::Windows, craft_keymap::Platform::Linux, craft_keymap::Platform::Mac] { + let km = crate::keymap::keymap_for(p, &snapshot()); + for (key, primary, alternate) in [("edit.cut", "Cmd+X", "F2"), ("edit.copy", "Cmd+C", "F3"), ("edit.paste", "Cmd+V", "F4")] { + let shown = dialog_shortcuts_with(&km, key, None).unwrap(); + let primary = menus::pretty_shortcut(primary); + assert!(shown.contains(&primary), "{p:?} {key}: {shown}"); + assert_eq!(shown.contains(alternate), !p.is_mac(), "{p:?} {key}: {shown}"); + } + } + for (key, primary) in [("edit.cut", "Cmd+X"), ("edit.copy", "Cmd+C"), ("edit.paste", "Cmd+V")] { + let shown = menus::item_shortcut(key, &Value::Null).map(menus::pretty_shortcut); + assert_eq!(shown, Some(menus::pretty_shortcut(primary)), "{key} menu shortcut"); + } + } + + #[test] + fn saved_function_key_assignment_does_not_change_cut_in_the_editor() { + if crate::keymap::platform().is_mac() { + return; + } + let mut saved = Overrides::new(); + crate::keymap::set_override(&mut saved, "tool:pen", "F2"); + let mirror = Mirror::new(saved.clone()); + assert!(!mirror.text.contains_key("edit.cut")); + assert!(conflicts_in(&mirror.keymap).is_empty()); + + let view = DialogView::new(saved.clone()); + assert_eq!(view.source, stored::to_value(&saved)); + assert_eq!(view.conflicts, 0); + assert_eq!(dialog_shortcuts_with(&view.keymap, "edit.cut", None), Some(menus::pretty_shortcut("Cmd+X"))); + + let mut other = saved.clone(); + assign(&mut other, "edit.preferences", Some("Cmd+Alt+Shift+F12"), false).unwrap(); + assert!(!crate::keymap::has_override(&other, "edit.cut")); + let (set, imported) = import_json(&export_json("Mine", &other)).unwrap(); + assert_eq!(set, "Mine"); + assert_eq!(imported, other); + assert!(!crate::keymap::has_override(&imported, "edit.cut")); + + let mut km = view.keymap.clone(); + assign_in(&mut km, "edit.preferences", Some("Cmd+Alt+Shift+F12"), false).unwrap(); + assert_eq!(saved_overrides(km.overrides(), &saved), other); + } + + #[test] + fn reset_stolen_edit_function_keys_restores_their_defaults() { + if crate::keymap::platform().is_mac() { + return; + } + for (alias, edit) in [("F2", "edit.cut"), ("F3", "edit.copy"), ("F4", "edit.paste")] { + let mut saved = Overrides::new(); + assert_eq!(assign(&mut saved, "tool:pen", Some(alias), true).unwrap(), vec![edit]); + assert!(!crate::keymap::has_override(&saved, edit), "{alias} saved edit override"); + reset_one(&mut saved, "tool:pen"); + assert_eq!(crate::keymap::lookup(&keymap_with(&saved), &crate::keymap::vc_chord(alias).unwrap()), Some(command_of(edit))); + + let mut d = Dialog::new("shortcuts", json!({"overrides": {}})); + let source = dialog_overrides(&d); + let mut km = keymap_with(&source); + record(&mut d, &mut km, "tool:pen", alias); + d.fields.insert("overrides".into(), stored::to_value(&saved_overrides(km.overrides(), &source))); + let mut dialog_saved = dialog_overrides(&d); + assert!(!crate::keymap::has_override(&dialog_saved, edit), "{alias} dialog edit override"); + reset_one(&mut dialog_saved, "tool:pen"); + assert_eq!(crate::keymap::lookup(&keymap_with(&dialog_saved), &crate::keymap::vc_chord(alias).unwrap()), Some(command_of(edit))); + + let mut changed = Overrides::new(); + crate::keymap::set_override(&mut changed, edit, "Ctrl+Alt+Shift+F12"); + assign(&mut changed, "tool:pen", Some(alias), true).unwrap(); + assert_eq!(crate::keymap::override_text(&changed, edit).as_deref(), Some("Ctrl+Alt+Shift+F12")); + } + } + + #[test] + fn reassigning_a_saved_function_key_gives_it_back_to_its_edit_command() { + if crate::keymap::platform().is_mac() { + return; + } + for (alias, edit) in [("F2", "edit.cut"), ("F3", "edit.copy"), ("F4", "edit.paste")] { + let mut saved = Overrides::new(); + crate::keymap::set_override(&mut saved, "edit.preferences", alias); + assign(&mut saved, "edit.preferences", Some("Cmd+Alt+Shift+F12"), false).unwrap(); + assert!(!crate::keymap::has_override(&saved, edit), "{alias} saved edit override"); + assert_eq!(crate::keymap::lookup(&keymap_with(&saved), &crate::keymap::vc_chord(alias).unwrap()), Some(command_of(edit))); + + let mut source = Overrides::new(); + crate::keymap::set_override(&mut source, "edit.preferences", alias); + let mut d = Dialog::new("shortcuts", json!({"overrides": stored::to_value(&source)})); + let mut km = keymap_with(&source); + record(&mut d, &mut km, "edit.preferences", "Cmd+Alt+Shift+F12"); + let dialog_saved = saved_overrides(km.overrides(), &source); + assert!(!crate::keymap::has_override(&dialog_saved, edit), "{alias} dialog edit override"); + assert_eq!(crate::keymap::lookup(&keymap_with(&dialog_saved), &crate::keymap::vc_chord(alias).unwrap()), Some(command_of(edit))); + } + } + + #[test] + fn assigning_an_edit_function_key_elsewhere_reports_the_conflict() { + if crate::keymap::platform().is_mac() { + return; + } + for (alias, edit) in [("F2", "edit.cut"), ("F3", "edit.copy"), ("F4", "edit.paste")] { + let label = entry(edit).map(|e| e.label.clone()).unwrap(); + assert_eq!(conflicts_with(&Overrides::new(), alias, "tool:pen"), vec![edit.to_string()], "{alias}"); + let mut ov = Overrides::new(); + let err = assign(&mut ov, "tool:pen", Some(alias), false).unwrap_err(); + assert!(err.ends_with(&format!("is already used by {label}")), "{alias}: {err}"); + assert!(!crate::keymap::has_override(&ov, "tool:pen"), "{alias}: a refused assignment saves nothing"); + + let mut d = Dialog::new("shortcuts", json!({"overrides": {}})); + let mut km = keymap_with(&Overrides::new()); + record(&mut d, &mut km, "tool:pen", alias); + assert_eq!(d.str("__conflict"), edit, "{alias}"); + assert!(d.str("__message").contains(&label), "{alias}: {}", d.str("__message")); + assert_eq!(crate::keymap::lookup(&km, &crate::keymap::vc_chord(alias).unwrap()), Some(command_of("tool:pen")), "{alias}"); + } + } + #[test] fn entries_cover_tools_and_menu_commands() { assert!(entries().iter().any(|e| e.key == "tool:selection")); @@ -1679,7 +2022,7 @@ mod tests { #[test] fn duplicate_can_be_given_a_shortcut() { // Duplicate has no default chord (#1539): it must still be listed so one can be set. - assert_eq!(entry("edit.duplicate").map(|e| e.group.as_str()), Some("Edit")); + assert_eq!(entry("edit.duplicate").as_ref().map(|e| e.group.as_str()), Some("Edit")); let mut ov = Overrides::new(); assign(&mut ov, "edit.duplicate", Some("Cmd+Alt+Shift+F8"), false).unwrap(); assert_eq!(effective_in(&ov, "edit.duplicate").as_deref(), Some("Cmd+Alt+Shift+F8")); @@ -1727,6 +2070,53 @@ mod tests { .collect() } + /// An open dialog lists a live-effect plug-in installed while it is open, with the shortcut its + /// working set gives it, and drops the row while the plug-in is removed, keeping that shortcut + /// in the working set. + #[test] + fn an_open_dialog_follows_live_effect_plugins() { + use vectorcraft_plugins::wat as tpl; + let (plugin, key) = ("test.ui.openfx", "effect:plugin.test.ui.openfx"); + let wasm = wat::parse_str(tpl::module(&tpl::manifest(plugin, "effect", r#"{"wiggle":{"type":"bool"}}"#), tpl::ECHO, "", 1)).unwrap(); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + open(&mut app); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("tab".into(), json!("menu")); + d.fields.insert("query".into(), json!("test.ui.openfx")); + let mut working = Overrides::new(); + crate::keymap::set_override(&mut working, key, "Cmd+Alt+Shift+F5"); + d.fields.insert("overrides".into(), stored::to_value(&working)); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let texts = |app: &mut VectorcraftApp| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) => Some(t.galley.text().to_string()), + _ => None, + }) + .collect::>() + }; + let row = "Effect › Plug-ins › Test test.ui.openfx…"; + let shortcut = menus::pretty_shortcut("Cmd+Alt+Shift+F5"); + // The modal draws nothing in its first frame, which only sizes it. + texts(&mut app); + assert!(!texts(&mut app).iter().any(|t| t == row)); + vectorcraft_plugins::registry::install_bytes(&wasm).unwrap(); + let shown = texts(&mut app); + assert!(shown.iter().any(|t| t == row) && shown.contains(&shortcut), "{shown:?}"); + vectorcraft_plugins::registry::remove(plugin); + assert!(!texts(&mut app).iter().any(|t| t == row)); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(crate::keymap::override_text(&dialog_overrides(d), key).as_deref(), Some("Cmd+Alt+Shift+F5")); + vectorcraft_plugins::registry::install_bytes(&wasm).unwrap(); + let shown = texts(&mut app); + assert!(shown.iter().any(|t| t == row) && shown.contains(&shortcut), "{shown:?}"); + vectorcraft_plugins::registry::remove(plugin); + } + /// A menu command whose params need a value (Units needs `{units}`) is not listed while it has /// no shortcut. A shortcut the user gave it keeps it listed, where it can be changed or cleared. #[test] @@ -1752,10 +2142,11 @@ mod tests { // whose params are all optional, one the menu bar runs without params (Rotate… opens its // dialog) and one that opens a dialog for its values (New Variable). let none = Overrides::new(); - let dropped: Vec<&str> = entries().iter().filter(|e| default_of(&e.key).is_some() && !listed(&none, e)).map(|e| e.key.as_str()).collect(); + let all = entries(); + let dropped: Vec<&str> = all.iter().filter(|e| default_of(&e.key).is_some() && !listed(&none, e)).map(|e| e.key.as_str()).collect(); assert!(dropped.is_empty(), "{dropped:?}"); for id in ["edit.duplicate", "object.pathfinder.unite", "object.rotate", "variable.define", "object.distributeSpacing"] { - assert!(entry(id).is_some_and(|e| listed(&none, e)), "{id}"); + assert!(entry(id).is_some_and(|e| listed(&none, &e)), "{id}"); } for (doc, required) in [ ("{}", false), @@ -1784,12 +2175,12 @@ mod tests { let others = ["swatch.unused", "graphicStyle.unused", "document.info", "view.proofSetup", "magicWand.set", "edit.findNext"]; for id in NEEDS_A_PARAM.iter().chain(&others) { let e = entry(id).unwrap_or_else(|| panic!("{id} has an entry")); - assert!(!listed(&none, e), "{id}"); + assert!(!listed(&none, &e), "{id}"); assert_eq!(default_of(id), None, "{id}"); } let mut ov = Overrides::new(); crate::keymap::set_override(&mut ov, "transparency.set", "Cmd+Alt+Shift+F10"); - assert!(entry("transparency.set").is_some_and(|e| listed(&ov, e))); + assert!(entry("transparency.set").is_some_and(|e| listed(&ov, &e))); let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); for id in ["transparency.set", "transparency.setOpacityMask"] { let list = run_command(&mut app, "shortcuts.list", &json!({ "query": id })).unwrap().unwrap(); @@ -1804,7 +2195,7 @@ mod tests { "addon.color.printFixes", "edit.findReplace", ] { - assert!(entry(id).is_some_and(|e| listed(&none, e)), "{id}"); + assert!(entry(id).is_some_and(|e| listed(&none, &e)), "{id}"); } assert_eq!(menu_target("object.path.cleanUp").map(|(id, _)| id).as_deref(), Some("ui.paramDialog")); assert_eq!( @@ -1873,8 +2264,9 @@ mod tests { assert!(app.ui.shortcut_overrides.is_empty(), "{id} {p}"); assert_eq!(app.ui.shortcut_set, set, "{id} {p}"); } - let all = app.run("shortcuts.list", json!({})).unwrap(); - assert_eq!(app.run("shortcuts.list", json!({"query": null})), Ok(all)); + let (all, null_query) = + with_steady_plugins(|| (app.run("shortcuts.list", json!({})).unwrap(), app.run("shortcuts.list", json!({"query": null})))); + assert_eq!(null_query, Ok(all)); let r = app.run("shortcuts.set", json!({"id": "object.isolate", "shortcut": "Cmd+Alt+Shift+F11", "force": null})).unwrap(); assert_eq!(r["shortcut"], "Cmd+Alt+Shift+F11"); assert!(app.run("shortcuts.preset", json!({"name": null})).unwrap_err().starts_with("unknown preset")); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 713cf5dd4..6d7ac9474 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -99,7 +99,7 @@ pub(crate) fn is_modifier(key: Key) -> bool { /// command's params: `{}`, or `{panel}` for the panel shortcuts (`window.panel`). pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json::Value)> { let mut v = vec![]; - for c in vectorcraft_engine::command_specs() { + for c in vectorcraft_engine::command_specs().iter().chain(vectorcraft_engine::cmd::plugin::command_specs()) { if let Some(sc) = crate::menus::shortcut_of(c.id).and_then(parse) { v.push((sc, c.id, json!({}))); } @@ -116,11 +116,17 @@ pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json } // Effect menu items with shortcuts of their own (#3296): a chord bound to one is a command's // too, so the tool doesn't take Cmd+Enter bound to an effect before the keymap runs it. - for e in crate::shortcut_editor::effect_items() { + for e in crate::shortcut_editor::effect_items().iter() { if let Some(sc) = crate::shortcut_editor::effect_shortcut(&e.effect).and_then(parse) { v.push((sc, e.command, json!({ "effect": e.effect }))); } } + // Object › Plug-ins' filter items with shortcuts, for the same reason. + for f in crate::shortcut_editor::filter_items().iter() { + if let Some(sc) = crate::shortcut_editor::filter_shortcut(&f.id).and_then(parse) { + v.push((sc, "plugin.dialog", json!({ "id": f.id }))); + } + } // Most specific (most modifiers) first: `paste_command` takes the first V chord the held keys // match logically, so Cmd+Shift+V isn't taken by Cmd+V. v.sort_by_key(|(sc, ..)| { @@ -204,6 +210,11 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { crate::ime::controls_without_editor(app, ctx); return; } + // The update prompt and the modal result window draw over everything and read Escape themselves. + if crate::updates_ui::blocks_input(app) { + crate::ime::controls_without_editor(app, ctx); + return; + } if app.ui.dialog.is_some() || app.ui.palette_open { crate::ime::controls_without_editor(app, ctx); if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) && crate::dialogs::revolve_gizmo::dragging(ctx) { diff --git a/crates/ui-egui/src/shortcuts/held_tool.rs b/crates/ui-egui/src/shortcuts/held_tool.rs index a3a809b96..5d69422a0 100644 --- a/crates/ui-egui/src/shortcuts/held_tool.rs +++ b/crates/ui-egui/src/shortcuts/held_tool.rs @@ -91,9 +91,9 @@ pub(super) fn follow(app: &mut VectorcraftApp, ctx: &egui::Context, focused: boo // Each pass looks at the key held last; one that goes back hands over to the key under it. loop { let gesture = app.session.tool_busy() || app.session.pointer_held(); - // A dialog, or a canvas drag that does not reach the tool (a pan, zoom or turn of the view), - // defers the decision. - let dialog = app.ui.dialog.is_some() || crate::canvas::dragging(ctx); + // A dialog, an update dialog, or a canvas drag that does not reach the tool (a pan, zoom or + // turn of the view), defers the decision. + let dialog = app.ui.dialog.is_some() || crate::updates_ui::blocks_input(app) || crate::canvas::dragging(ctx); let active = app.session.tool_id(); let on = app.session.prefs.hold_tool_keys; let Some(held) = app.held_tool.last_mut() else { return }; diff --git a/crates/ui-egui/src/shortcuts/held_tool/tests.rs b/crates/ui-egui/src/shortcuts/held_tool/tests.rs index e2217b822..c272024ad 100644 --- a/crates/ui-egui/src/shortcuts/held_tool/tests.rs +++ b/crates/ui-egui/src/shortcuts/held_tool/tests.rs @@ -75,6 +75,21 @@ fn pen_app() -> VectorcraftApp { app } +/// S let go while the update prompt is open leaves Scale chosen until the prompt closes. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn a_tool_key_let_go_behind_the_update_prompt_returns_after_it() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + crate::updates_ui::open_prompt_for_test(&mut app); + keys.release_s(&mut app, 1.0); + assert_eq!(app.session.tool_id(), "scale", "Scale stays behind the prompt"); + crate::updates_ui::close_dialogs_for_test(&mut app); + keys.frame(&mut app, 1.1, vec![], None); + assert_eq!(app.session.tool_id(), "pen", "the Pen is back after the prompt"); +} + /// S held from 0 s, its repeats at `repeats`, let go at `release`. fn hold_s(app: &mut VectorcraftApp, keys: &Keyboard, repeats: &[f64], release: f64) { keys.press_s(app, 0.0); diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index b64dd4f7a..44d438b73 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -28,6 +28,53 @@ fn named_panel_default_chords_toggle_their_panels() { } } +#[test] +fn edit_function_keys_dispatch_outside_text_editing_only() { + if crate::keymap::platform().is_mac() { + return; + } + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let shape = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [shape]})).unwrap(); + crate::shortcut_editor::sync(&app.ui); + frame(&mut app, press(Key::F3, Modifiers::NONE)); + assert_eq!(app.session.clipboard.nodes.len(), 1, "F3 copies the selection"); + frame(&mut app, press(Key::F2, Modifiers::NONE)); + assert!(app.session.active().unwrap().selection.objects.is_empty(), "F2 cuts the selection"); + frame(&mut app, press(Key::F4, Modifiers::NONE)); + assert_eq!(app.session.active().unwrap().selection.objects.len(), 1, "F4 pastes the copied object"); + + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert!(app.session.tool_wants_text()); + for key in [Key::F2, Key::F3, Key::F4] { + frame(&mut app, press(key, Modifiers::NONE)); + assert_eq!(text_and_layers(&mut app, id).0, "Hi", "{key:?} leaves edited text alone"); + assert!(app.session.tool_wants_text(), "{key:?} keeps editing"); + } + + app.select_tool("selection"); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + let mut field = String::from("Field text"); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let response = ui.text_edit_singleline(&mut field); + ui.ctx().memory_mut(|m| m.request_focus(response.id)); + }); + out.textures_delta.clear(); + for key in [Key::F2, Key::F3, Key::F4] { + let mut out = ctx.run_ui(egui::RawInput { events: press(key, Modifiers::NONE), ..Default::default() }, |ui| { + handle(&mut app, ui.ctx()); + ui.text_edit_singleline(&mut field); + }); + out.textures_delta.clear(); + assert_eq!(field, "Field text", "{key:?} leaves focused text alone"); + assert_eq!(app.session.active().unwrap().selection.objects.len(), 1, "{key:?} leaves the canvas selection alone"); + } +} + #[test] fn other_panel_shortcuts_keep_plain_panel_params() { for panel in ["character", "swatches"] { @@ -510,7 +557,7 @@ fn cmd_alt_k_and_cmd_alt_shift_f_highlight_the_character_fields() { assert!(app.ui.dialog.is_none(), "not Preferences"); // Keyboard Shortcuts lists all three under Type; Highlight Tracking has no default key. for id in ["type.highlightFontName", "type.highlightKerning", "type.highlightTracking"] { - assert_eq!(crate::shortcut_editor::entry(id).map(|e| e.group.as_str()), Some("Type"), "{id}"); + assert_eq!(crate::shortcut_editor::entry(id).as_ref().map(|e| e.group.as_str()), Some("Type"), "{id}"); } assert_eq!(crate::menus::shortcut_of("type.highlightTracking"), None); } @@ -2372,7 +2419,7 @@ fn all_registered_shortcuts_parse() { /// dialog for the selection. #[test] fn an_effect_menu_item_takes_a_shortcut_of_its_own() { - let entry = crate::shortcut_editor::entries().iter().find(|e| e.key == "effect:distort.transform").expect("Transform is listed"); + let entry = crate::shortcut_editor::entry("effect:distort.transform").expect("Transform is listed"); assert_eq!(entry.group, "Effect › Distort & Transform"); assert_eq!(crate::keymap::command_of("effect:distort.transform").id, "effect.dialog"); let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); @@ -2413,6 +2460,234 @@ fn cmd_enter_bound_to_an_effect_runs_the_effect_not_the_tool() { crate::shortcut_editor::sync(&crate::state::UiState::default()); } +/// A live-effect plug-in installed after Keyboard Shortcuts built its list has a row of its own: +/// it takes a shortcut, `shortcuts.list` and its Effect › Plug-ins item show it, and the key +/// applies the effect. While the plug-in is removed it has no row and no key is bound to it; the +/// saved shortcut is the effect's again when the plug-in comes back, now opening the dialog of the +/// options it has. +#[test] +fn a_live_effect_plugin_installed_later_takes_a_shortcut_of_its_own() { + use vectorcraft_plugins::wat as tpl; + let (plugin, effect, key) = ("test.ui.keyedfx", "plugin.test.ui.keyedfx", "effect:plugin.test.ui.keyedfx"); + let install = |params: &str| { + let src = tpl::module(&tpl::manifest(plugin, "effect", params), tpl::ECHO, "", 1); + vectorcraft_plugins::registry::install_bytes(&wat::parse_str(src).unwrap()).unwrap(); + }; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let node = vectorcraft_doc::NodeId(id.as_u64().unwrap()); + let effects = + |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(node).unwrap().appearance.effects.iter().filter(|e| e.id == effect).count(); + let shown = |app: &mut VectorcraftApp| { + let list = crate::shortcut_editor::run_command(app, "shortcuts.list", &json!({ "query": effect })).unwrap().unwrap(); + list.as_array().unwrap().iter().map(|e| (e["id"].as_str().unwrap_or_default().to_string(), e["shortcut"].clone())).collect::>() + }; + let chord = "Cmd+Alt+Shift+F6"; + let f6 = || press(Key::F6, cmd() | Modifiers::ALT | Modifiers::SHIFT); + // The lists and the keymap in effect are built before the plug-in is installed. + assert!(crate::shortcut_editor::entry(key).is_none()); + crate::shortcut_editor::sync(&app.ui); + assert!(crate::shortcuts::all_shortcuts().iter().all(|(_, _, p)| p["effect"] != effect)); + install("{}"); + let e = crate::shortcut_editor::entry(key).expect("listed once installed"); + assert_eq!((e.label.as_str(), e.group.as_str()), ("Test test.ui.keyedfx", "Effect › Plug-ins")); + assert_eq!(crate::keymap::command_of(key).id, "effect.apply", "it has no options"); + let set = crate::shortcut_editor::run_command(&mut app, "shortcuts.set", &json!({"id": key, "shortcut": chord})).unwrap().unwrap(); + assert_eq!(set["shortcut"], chord); + assert_eq!(shown(&mut app), vec![(key.to_string(), json!(chord))]); + assert_eq!(crate::menus::item_shortcut("effect.apply", &json!({ "effect": effect })), Some(chord)); + frame(&mut app, f6()); + assert_eq!(effects(&app), 1, "the key applies the effect"); + // Removed: no row, and no key is bound to the effect; the saved shortcut stays. + vectorcraft_plugins::registry::remove(plugin); + assert!(crate::shortcut_editor::entry(key).is_none()); + assert_eq!(shown(&mut app), vec![]); + assert!(crate::shortcuts::all_shortcuts().iter().all(|(_, _, p)| p["effect"] != effect), "no key is bound to the removed effect"); + assert!(crate::keymap::has_override(&app.ui.shortcut_overrides, key)); + // Back, now with options: the saved shortcut opens its dialog. + install(r#"{"wiggle":{"type":"bool"}}"#); + assert_eq!(crate::keymap::command_of(key).id, "effect.dialog"); + assert_eq!(shown(&mut app), vec![(key.to_string(), json!(chord))]); + assert_eq!(crate::menus::item_shortcut("effect.dialog", &json!({ "effect": effect })), Some(chord)); + frame(&mut app, f6()); + let d = app.ui.dialog.as_ref().expect("the effect's dialog opens"); + assert_eq!(d.fields.get("__effect").and_then(serde_json::Value::as_str), Some(effect), "{:?}", d.kind); + vectorcraft_plugins::registry::remove(plugin); + crate::shortcut_editor::sync(&crate::state::UiState::default()); +} + +/// Each filter plug-in's Object › Plug-ins item has a row of its own (`plugin:`), with no +/// default shortcut, even when two filters share a name: a key given to one shows in its menu item +/// and opens that filter's dialog, and the other stays unbound. While the plug-in is removed it +/// has no row and no key is bound to it; the saved shortcut is the filter's again when it comes +/// back, now running it at once since it has no parameters. +#[test] +fn each_filter_plugin_takes_a_shortcut_of_its_own() { + use serde_json::Value; + use vectorcraft_plugins::wat as tpl; + let (one, two) = ("test.UI.key_filter-one", "test.UI.key_filter-two"); + let (key_one, key_two) = (format!("plugin:{one}"), format!("plugin:{two}")); + let install = |id: &str, name: &str, params: &str| { + let manifest = format!(r#"{{"id":"{id}","name":"{name}","version":"1","kind":"filter","params":{params}}}"#); + vectorcraft_plugins::registry::install_bytes(&wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap()).unwrap(); + }; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let shown = |app: &mut VectorcraftApp| { + let list = crate::shortcut_editor::run_command(app, "shortcuts.list", &json!({ "query": "test.ui.key_filter-" })).unwrap().unwrap(); + list.as_array().unwrap().iter().map(|e| (e["id"].as_str().unwrap_or_default().to_string(), e["shortcut"].clone())).collect::>() + }; + let chord = "Cmd+Alt+Shift+F7"; + let f7 = || press(Key::F7, cmd() | Modifiers::ALT | Modifiers::SHIFT); + crate::shortcut_editor::sync(&app.ui); + install(one, "Same Name", "{}"); + install(two, "Same Name", r#"{"wiggle":{"type":"bool"}}"#); + let rows: Vec<_> = [&key_one, &key_two].iter().map(|k| crate::shortcut_editor::entry(k).map(|e| (e.label, e.group, e.is_tool))).collect(); + let group = "Object › Plug-ins".to_string(); + assert_eq!(rows, [Some(("Same Name".into(), group.clone(), false)), Some(("Same Name…".into(), group.clone(), false))]); + for key in [&key_one, &key_two] { + assert_eq!(crate::shortcut_editor::default_of(key), None, "no default shortcut"); + let command = crate::keymap::command_of(key); + assert_eq!(command.id, "plugin.dialog"); + assert_eq!(crate::keymap::entry_key(&command), *key, "the id is kept whole"); + assert_eq!(crate::keymap::entry_of_stored(&crate::keymap::stored_key(key)), *key); + } + assert_eq!(shown(&mut app), vec![(key_one.clone(), Value::Null), (key_two.clone(), Value::Null)]); + let set = crate::shortcut_editor::run_command(&mut app, "shortcuts.set", &json!({"id": key_two, "shortcut": chord})).unwrap().unwrap(); + assert_eq!(set["shortcut"], chord); + assert_eq!(shown(&mut app), vec![(key_one.clone(), Value::Null), (key_two.clone(), json!(chord))]); + assert_eq!(crate::menus::item_shortcut("plugin.dialog", &json!({ "id": two })), Some(chord)); + assert_eq!(crate::menus::item_shortcut("plugin.dialog", &json!({ "id": one })), None); + let text = crate::shortcut_editor::export_text("Custom", &app.ui.shortcut_overrides); + let lines: Vec<&str> = text.lines().filter(|l| l.starts_with("Object › Plug-ins › Same Name")).collect(); + assert_eq!(lines.len(), 2, "{text}"); + assert_eq!(lines[0], "Object › Plug-ins › Same Name\t"); + assert_eq!(lines[1], format!("Object › Plug-ins › Same Name…\t{}", crate::menus::pretty_shortcut(chord))); + // A chord an Effect menu item holds is a conflict. + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "effect:distort.transform", "Cmd+Alt+Shift+F8"); + let conflicts = crate::shortcut_editor::conflicts_with(&app.ui.shortcut_overrides, "Cmd+Alt+Shift+F8", &key_one); + assert_eq!(conflicts, vec!["effect:distort.transform".to_string()]); + crate::keymap::remove_override(&mut app.ui.shortcut_overrides, "effect:distort.transform"); + frame(&mut app, f7()); + let d = app.ui.dialog.take().expect("the filter's dialog opens"); + assert_eq!((d.kind.as_str(), d.str("__plugin")), ("plugin", two.to_string())); + let bound = |app: &VectorcraftApp| { + crate::keymap::vc_chord(chord).and_then(|c| crate::keymap::lookup(&crate::keymap::keymap_with(&app.ui.shortcut_overrides), &c)) + }; + assert_eq!(bound(&app).map(|c| crate::keymap::entry_key(&c)), Some(key_two.clone())); + // Removed: no row, and no key is bound to the filter; the saved shortcut stays. + vectorcraft_plugins::registry::remove(two); + assert_eq!(bound(&app), None, "the key runs nothing"); + assert!(crate::shortcut_editor::entry(&key_two).is_none()); + assert_eq!(shown(&mut app), vec![(key_one.clone(), Value::Null)]); + assert!(crate::shortcuts::all_shortcuts().iter().all(|(_, _, p)| p["id"] != two), "no key is bound to the removed filter"); + assert!(crate::keymap::has_override(&app.ui.shortcut_overrides, &key_two)); + // Back under another name and without parameters: the saved shortcut runs it at once. + install(two, "Renamed", "{}"); + assert_eq!(crate::shortcut_editor::entry(&key_two).map(|e| e.label), Some("Renamed".into())); + assert_eq!(shown(&mut app), vec![(key_one.clone(), Value::Null), (key_two.clone(), json!(chord))]); + let undo = app.session.doc().unwrap().history.undo.len(); + frame(&mut app, f7()); + assert!(app.ui.dialog.is_none(), "no dialog: {:?}", app.ui.dialog.as_ref().map(|d| &d.kind)); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "the filter ran as one undo step"); + vectorcraft_plugins::registry::remove(one); + vectorcraft_plugins::registry::remove(two); + crate::shortcut_editor::sync(&crate::state::UiState::default()); +} + +/// A filter plug-in replaced under its id, or reinstalled from a folder by Reload Plug-ins, keeps +/// its shortcut, which runs the plug-in as it is now. With no document open the key does nothing, +/// as the dimmed menu item does. +#[test] +fn a_filter_plugin_keeps_its_shortcut_when_replaced_or_reloaded() { + use vectorcraft_plugins::wat as tpl; + let id = "test.UI.replaced_filter"; + let key = format!("plugin:{id}"); + let wasm = |name: &str, params: &str| { + let manifest = format!(r#"{{"id":"{id}","name":"{name}","version":"1","kind":"filter","params":{params}}}"#); + wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap() + }; + let with_params = r#"{"wiggle":{"type":"bool"}}"#; + let chord = "Cmd+Alt+Shift+F6"; + let f6 = || press(Key::F6, cmd() | Modifiers::ALT | Modifiers::SHIFT); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + vectorcraft_plugins::registry::install_bytes(&wasm("Original", "{}")).unwrap(); + crate::shortcut_editor::run_command(&mut app, "shortcuts.set", &json!({"id": key, "shortcut": chord})).unwrap().unwrap(); + + vectorcraft_plugins::registry::install_bytes(&wasm("Replaced", with_params)).unwrap(); + assert_eq!(crate::shortcut_editor::entry(&key).map(|e| e.label), Some("Replaced…".into())); + assert_eq!(crate::menus::item_shortcut("plugin.dialog", &json!({ "id": id })), Some(chord)); + frame(&mut app, f6()); + assert_eq!(app.ui.dialog.take().expect("the replaced filter has parameters").str("__plugin"), id); + + let folder = std::env::temp_dir().join(format!("vectorcraft-ui-filter-shortcut-{}", std::process::id())); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write(folder.join("filter.wasm"), wasm("Reloaded", with_params)).unwrap(); + app.run("plugin.reload", json!({ "path": folder.display().to_string() })).unwrap(); + std::fs::remove_dir_all(&folder).ok(); + assert_eq!(crate::shortcut_editor::entry(&key).map(|e| e.label), Some("Reloaded…".into())); + assert_eq!(crate::menus::item_shortcut("plugin.dialog", &json!({ "id": id })), Some(chord)); + + let mut empty = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + empty.ui.shortcut_overrides = app.ui.shortcut_overrides.clone(); + frame(&mut empty, f6()); + assert!(empty.ui.dialog.is_none(), "no document: no dialog"); + vectorcraft_plugins::registry::remove(id); + crate::shortcut_editor::sync(&crate::state::UiState::default()); +} + +/// The key of a heavy filter does what a click on its item does: one without parameters starts in +/// the background, and one with parameters opens its dialog, which has no Preview. +#[test] +fn a_heavy_filter_plugin_runs_from_its_shortcut_as_from_its_menu_item() { + use vectorcraft_plugins::wat as tpl; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.allow_plugin_grants_from_requests(); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let (at_once, asks) = ("test.UI.heavy_filter-at-once", "test.UI.heavy_filter-asks"); + for (id, params, chord) in [(at_once, "{}", "Cmd+Alt+Shift+F7"), (asks, r#"{"wiggle":{"type":"bool"}}"#, "Cmd+Alt+Shift+F8")] { + let manifest = format!(r#"{{"id":"{id}","name":"Heavy {id}","version":"1","kind":"filter","budget":"heavy","params":{params}}}"#); + let wasm = wat::parse_str(tpl::spinning(&manifest, 1 << 40, r#"{"objects":[]}"#)).unwrap(); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "allowHeavy": true})).unwrap(); + crate::shortcut_editor::run_command(&mut app, "shortcuts.set", &json!({"id": format!("plugin:{id}"), "shortcut": chord})).unwrap().unwrap(); + } + + frame(&mut app, press(Key::F7, cmd() | Modifiers::ALT | Modifiers::SHIFT)); + assert_eq!(app.plugin_runs.running.len(), 1, "started in the background"); + assert_eq!(app.ui.status, format!("Running “Heavy {at_once}”…")); + + frame(&mut app, press(Key::F8, cmd() | Modifiers::ALT | Modifiers::SHIFT)); + let d = app.ui.dialog.take().expect("the heavy filter with parameters opens its dialog"); + assert_eq!(d.str("__plugin"), asks); + assert!(d.fields.get("preview").is_none(), "no Preview"); + + assert_eq!(app.run("plugin.cancelRuns", json!({})).unwrap()["cancelled"], 1); + vectorcraft_plugins::registry::remove(at_once); + vectorcraft_plugins::registry::remove(asks); + crate::shortcut_editor::sync(&crate::state::UiState::default()); +} + +/// Keyboard Shortcuts translates a menu path one segment at a time and the label of a built-in +/// item. A filter plug-in's name stays as the plug-in gives it, even when it reads like a command. +#[test] +fn a_filter_plugin_row_translates_its_path_and_not_its_name() { + let zh = crate::i18n::Lang::from_code("zh-hant").unwrap(); + crate::i18n::set_current(zh); + let entry = + |key: &str| crate::shortcut_editor::Entry { key: key.into(), label: "Group".into(), group: "Object › Plug-ins".into(), is_tool: false }; + let path = format!("{} › {}", crate::i18n::tr(zh, "Object"), crate::i18n::tr(zh, "Plug-ins")); + let (filter, command) = (crate::shortcut_editor::shown_name(&entry("plugin:a.b")), crate::shortcut_editor::shown_name(&entry("object.group"))); + crate::i18n::set_current(crate::i18n::Lang::EN); + assert_ne!(path, "Object › Plug-ins"); + assert_eq!(filter, format!("{path} › Group")); + assert_eq!(command, format!("{path} › {}", crate::i18n::tr(zh, "Group"))); +} + /// #2898 review: Up and Down change the corner kind only while a corner widget is pressed. With /// none pressed, a plain Up still nudges the selected rectangle and leaves its corners alone; with /// one pressed, Up steps the kind and the rectangle stays where it is. diff --git a/crates/ui-egui/src/shortcuts/typing.rs b/crates/ui-egui/src/shortcuts/typing.rs index a3127adde..cee164c7f 100644 --- a/crates/ui-egui/src/shortcuts/typing.rs +++ b/crates/ui-egui/src/shortcuts/typing.rs @@ -27,7 +27,7 @@ pub(crate) fn keep_marked_text(app: &mut VectorcraftApp) { app.ime_discard = true; if app.session.tool_composing() { // Errors are already reported by the engine's interaction. - let _ = app.session.tool_text(&m, app.view_info()); + let _ = app.session.tool_typed_text(&m, app.view_info()); } } @@ -260,7 +260,7 @@ fn apply_type_event( Seq::Text(t) if !app.session.tool_composing() && !shortcut_mods => { let target_before = crate::ime::target(&mut app.session, view); let before = crate::ime::stamp(&mut app.session, view); - let r = app.session.tool_text(&t, view); + let r = app.session.tool_typed_text(&t, view); if r.is_ok() { crate::ime::note_application_edit(app, ctx, view, target_before, before); } @@ -283,7 +283,7 @@ fn apply_type_event( let composing = app.session.tool_composing(); let verbatim = app.session.tool_ime_session_matches(view) && match app.ime_marked.as_deref() { - Some(m) => app.session.tool_text_is_verbatim(m, view), + Some(m) => app.session.tool_typed_text_is_verbatim(m, view), None => true, }; keep_marked_text(app); @@ -306,8 +306,8 @@ fn apply_type_event( let before = crate::ime::stamp(&mut app.session, view); // A document edit since the composition means this commit starts a fresh typing // session: that reset is application-side, so it cannot earn `InputMethod`. - let verbatim = app.session.tool_ime_session_matches(view) && app.session.tool_text_is_verbatim(&t, view); - let r = app.session.tool_text(&t, view); + let verbatim = app.session.tool_ime_session_matches(view) && app.session.tool_typed_text_is_verbatim(&t, view); + let r = app.session.tool_typed_text(&t, view); if r.is_ok() { if verbatim { crate::ime::note_ime_apply(app, ctx, view, target_before, before); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 85f87b723..95ffcd9cd 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -74,8 +74,9 @@ pub const ROTATE_VIEW_ANGLES: [(&str, f64); 12] = [ ("-90°", -90.0), ]; -use super::{ControlResponse, Sender}; +use super::ControlResponse; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::Sender; /// Zoom stop stepping reads this (set each UI frame and before commands) so canvas wheel zoom /// matches the status bar when Display Print Size at 100% Zoom is on. @@ -201,6 +202,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("attributes", "Attributes", "settings"), (crate::panels::svg_interactivity::ID, "SVG Interactivity", "spline-pointer"), ("colorThemes", "Color Themes", "sun"), + (crate::panels::color_wheel::ID, "Color Wheel", "circle"), (crate::panels::links::ID, "Links", "link"), (crate::panels::asset_export::ID, "Asset Export", "share-2"), (crate::panels::css_properties::ID, "CSS Properties", "globe"), @@ -370,6 +372,9 @@ pub struct UiState { /// That popup's first text field is still to take the keyboard. #[serde(skip)] pub transient_focus: bool, + /// Type › Find Font…'s last Find In choice (`find_font::FIND_IN`), kept while the app runs. + #[serde(skip)] + pub find_font_in: String, pub control_bar: bool, /// The Control bar is docked to the bottom of the window instead of the top /// (`window.control.dock`). @@ -460,10 +465,21 @@ pub struct UiState { #[serde(default, skip_serializing_if = "Option::is_none")] pub dock_width: Option, /// The Tools panel floats with its top-left corner here (dragged out by its title bar); `None`: - /// docked at the window's left edge. + /// docked at the window's left edge, or beside the canvas with [`Self::tools_beside_canvas`]. #[serde(default)] pub toolbar_pos: Option<[f32; 2]>, + /// The docked Tools panel sits between the left-side panels and the canvas instead of at the + /// window's left edge (Window › Toolbars › Keep Tools Beside the Canvas, + /// `window.toolsBesideCanvas`). Off by default, and off in state saved without it. + #[serde(default)] + pub tools_beside_canvas: bool, + /// The docked Tools panel stands on the right, between the canvas and the panels, instead of on + /// the left (dropped there, or `window.panel.dock {panel: "tools", side: "right"}`). Off by + /// default, and off in state saved without it. + #[serde(default)] + pub toolbar_right: bool, pub status_bar: bool, + pub help_bar: bool, pub dock: bool, /// The bars that Tab (`window.dock`) hid, which the next Tab shows again; none when Tab has not /// hidden them since it last showed them, or a workspace was applied since. @@ -628,6 +644,11 @@ pub struct UiState { /// place, [`crate::dialogs::settle`] puts the document back at the front of `pending_fonts`. #[serde(skip)] pub fonts_dialog: Option, + /// The plug-in (`{id, file, name, label}`) the last launch was loading when it stopped, while + /// the user has not chosen what to do about it. When another dialog takes the choice's place, + /// [`crate::dialogs::settle`] opens the choice again once no dialog is open. + #[serde(skip)] + pub stopped_plugin: Option, /// The search the Missing Fonts dialog started (`text.findFontFiles`'s `id`), stopped once the /// dialog is gone. #[serde(skip)] @@ -964,6 +985,7 @@ impl Default for UiState { open_panel: None, transient_panel: None, transient_focus: false, + find_font_in: String::new(), control_bar: false, control_bar_bottom: false, toolbar: true, @@ -992,7 +1014,10 @@ impl Default for UiState { docking_generation: 0, dock_width: None, toolbar_pos: None, + tools_beside_canvas: false, + toolbar_right: false, status_bar: true, + help_bar: true, dock: true, tab_hid: None, view: ViewFlags::default(), @@ -1043,6 +1068,7 @@ impl Default for UiState { made_text_style: vec![], pending_fonts: vec![], fonts_dialog: None, + stopped_plugin: None, dialog_search: None, links_search: None, dxf_options: Value::Null, diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 5139d4881..c66cc7cd5 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -159,6 +159,84 @@ fn shortcut_sets_are_read_only_inside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// `app.export` refuses a key no format takes before anything is written, with a path or without. +#[test] +fn control_export_refuses_an_option_no_format_takes() { + let (mut app, tx, base) = confined("export-options"); + let r = request(&mut app, &tx, "app.open", json!({"path": s(base.join("in/a.svg"))})); + assert_eq!(r["ok"], true, "{r}"); + let path = base.join("out/a.pdf"); + let refused = |r: &Value| r["error"].as_str().is_some_and(|e| e.contains("unknown parameter")); + for mut params in [json!({"format": "pdf", "noSuchOption": true}), json!({"format": "pdf", "marks": {"useDocumentBleed": true}})] { + let r = request(&mut app, &tx, "app.export", params.clone()); + assert!(refused(&r), "{r}"); + params["path"] = json!(s(path.clone())); + params["wait"] = json!(true); + let r = request(&mut app, &tx, "app.export", params); + assert!(refused(&r), "{r}"); + assert!(!path.exists()); + } + let r = request(&mut app, &tx, "app.export", json!({"format": "pdf", "path": s(path.clone()), "wait": true})); + assert_eq!(r["ok"], true, "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `app.save`, and `file.save` and `file.exportForScreens` run as commands, refuse a key no save +/// format or no format takes before anything is written. +#[test] +fn control_save_refuses_an_option_no_format_takes() { + let (mut app, tx, base) = confined("save-options"); + let r = request(&mut app, &tx, "app.open", json!({"path": s(base.join("in/a.svg"))})); + assert_eq!(r["ok"], true, "{r}"); + let path = base.join("out/a.pdf"); + let refused = |r: &Value| r["error"].as_str().is_some_and(|e| e.contains("unknown parameter")); + for params in [json!({"noSuchOption": true}), json!({"options": {"marks": {"useDocumentBleed": true}}})] { + let mut params = params; + params["path"] = json!(s(path.clone())); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "file.save", "params": params.clone()})); + assert!(refused(&r), "{r}"); + assert!(!path.exists()); + params["wait"] = json!(true); + let r = request(&mut app, &tx, "app.save", params); + assert!(refused(&r), "{r}"); + assert!(!path.exists()); + } + let folder = base.join("out/screens"); + for params in [json!({"noSuchOption": true}), json!({"formats": [{"format": "png", "noSuchOption": 1}]})] { + let mut params = params; + params["folder"] = json!(s(folder.clone())); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "file.exportForScreens", "params": params})); + assert!(refused(&r), "{r}"); + assert!(!folder.exists()); + } + let r = request(&mut app, &tx, "app.save", json!({"path": s(path.clone()), "wait": true})); + assert_eq!(r["ok"], true, "{r}"); + assert!(path.exists()); + let _ = std::fs::remove_dir_all(base); +} + +/// An action set another app saved (`.aia`) is read only from inside the read root, whichever way a +/// control request names it: `actionSet.read` and Load Actions…. +#[test] +fn action_sets_are_read_only_inside_the_read_root() { + let (mut app, tx, base) = confined("aia"); + let set = "/version 3\n/name [ 4 4d696e65 ]\n/action-1 { /name [ 1 41 ] }\n"; + std::fs::write(base.join("in/a.aia"), set).unwrap(); + std::fs::write(base.join("outside/b.aia"), set).unwrap(); + let execute = |command: &str, path: PathBuf| json!({"command": command, "params": {"path": s(path)}}); + let r = request(&mut app, &tx, "engine.execute", execute("actionSet.read", base.join("in/a.aia"))); + assert_eq!(r["result"]["set"], "Mine", "{r}"); + let r = request(&mut app, &tx, "engine.execute", execute(crate::panels::actions::LOAD_SET, base.join("in/a.aia"))); + assert_eq!(r["result"]["name"], "Mine", "{r}"); + let sets = app.ui.action_sets.len(); + for command in ["actionSet.read", crate::panels::actions::LOAD_SET] { + let r = request(&mut app, &tx, "engine.execute", execute(command, base.join("outside/b.aia"))); + assert!(r["error"].as_str().is_some_and(|e| e.contains("outside the read root")), "{command}: {r}"); + } + assert_eq!(app.ui.action_sets.len(), sets, "no set added"); + let _ = std::fs::remove_dir_all(base); +} + /// `ui.render` (MCP's `render_preview` and `screenshot` in the app) draws the artboard pixel for /// pixel as `app.export` writes a PNG on white: two rectangles that meet at x 40.3 and a diagonal /// edge. @@ -294,6 +372,147 @@ fn an_unidentifiable_stale_injected_batch_fails_closed() { let _ = std::fs::remove_dir_all(base); } +/// A plug-in's permissions are approved only by a person. A control request (or MCP, which comes +/// through it) can't pass `grant`, can't answer the approval dialog or rewrite what it installs, +/// and a frame of synthetic input can't press its OK; the person at the keyboard can. +#[test] +fn automation_cannot_approve_a_plugin_install() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = "test.ui.approval.automation"; + let manifest = format!(r#"{{"id":"{id}","name":"Approval","version":"1","kind":"filter","permissions":["doc.read"]}}"#); + let bytes = wat::parse_str(vectorcraft_plugins::wat::returning(&manifest, r#"{"objects":[]}"#)).unwrap(); + let data = vectorcraft_format::base64_encode(&bytes); + let refused = |r: &Value, says: &str| assert!(r["error"].as_str().is_some_and(|e| e.contains(says)), "{r}"); + + let r = request(&mut app, &tx, "engine.execute", json!({"command": "plugin.install", "params": {"dataBase64": data, "grant": ["doc.read"]}})); + refused(&r, "--allow-plugin-grants"); + assert!(app.ui.dialog.is_none()); + + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {"dataBase64": data}})); + assert_eq!(r["result"]["pending"], crate::dialogs::plugin::INSTALL_KIND, "{r}"); + let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); + refused(&r, "Only the person at the keyboard"); + assert!(app.ui.dialog.is_some(), "the refused answer leaves it open"); + assert!(vectorcraft_plugins::registry::get(id).is_none()); + + // What the dialog installs isn't in its fields. + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__params", "value": {"dataBase64": "AAAA"}})); + assert!(r.get("error").is_none(), "{r}"); + // What MCP's `answer_dialog` sends: `ui.dialog.set {fields}`, then `ui.dialog.confirm`. + let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "__params", "value": {"dataBase64": "AAAA"}}]})); + assert!(r.get("error").is_none(), "{r}"); + let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); + refused(&r, "Only the person at the keyboard"); + assert!(app.ui.dialog.is_some() && vectorcraft_plugins::registry::get(id).is_none()); + app.synthetic_frame = true; + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains("Only the person at the keyboard")); + assert!(vectorcraft_plugins::registry::get(id).is_none()); + app.synthetic_frame = false; + + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(vectorcraft_plugins::permissions::sha256_hex(&bytes))); + app.run("plugin.remove", json!({"id": id})).unwrap(); +} + +type DialogHarness = egui_kittest::Harness<'static, VectorcraftApp>; + +/// Runs one UI pass of `h` for each batch of synthetic input the control path queued, each taken +/// through the host's raw-input hook, until none is left and the last batch is acknowledged. +fn take_injected(h: &mut DialogHarness) { + let ctx = h.ctx.clone(); + loop { + let mut raw = egui::RawInput::default(); + h.state_mut().raw_input_hook(&ctx, &mut raw); + if raw.events.is_empty() && h.state().synthetic.is_empty() { + break; + } + h.input_mut().events.extend(raw.events); + h.step(); + } +} + +/// The open install approval, with nothing installed or granted for `id`. +fn still_waiting(h: &DialogHarness, id: &str) -> bool { + h.state().ui.dialog.as_ref().is_some_and(|d| d.kind == crate::dialogs::plugin::INSTALL_KIND) + && vectorcraft_plugins::registry::get(id).is_none() + && vectorcraft_plugins::registry::granted(id).is_none() +} + +/// Keys and pointer input sent through the control path approve nothing at the open install +/// approval: Enter and Space press nothing with no button focused, and with OK focused they and a +/// `ui.click` on it press it and are refused. `ui.pointer` drives the canvas. A person's click on +/// the same button approves. +#[test] +fn injected_keys_and_pointer_input_cannot_approve_a_plugin_install() { + use egui_kittest::kittest::{NodeT, Queryable}; + let refusal = "Only the person at the keyboard"; + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = "test.ui.approval.injected"; + let manifest = format!(r#"{{"id":"{id}","name":"Approval","version":"1","kind":"filter","permissions":["doc.read"]}}"#); + let bytes = wat::parse_str(vectorcraft_plugins::wat::returning(&manifest, r#"{"objects":[]}"#)).unwrap(); + let data = vectorcraft_format::base64_encode(&bytes); + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {"dataBase64": data}})); + assert_eq!(r["result"]["pending"], crate::dialogs::plugin::INSTALL_KIND, "{r}"); + let mut fonts = false; + let mut h: DialogHarness = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 800.0)).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + crate::dialogs::show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + + for key in ["Enter", "Space"] { + assert!(!h.get_by_label("OK").accesskit_node().is_focused(), "no button has the focus yet"); + h.state_mut().ui.status.clear(); + let r = request(h.state_mut(), &tx, "ui.key", json!({"key": key})); + assert_eq!(r["ok"], true, "{r}"); + take_injected(&mut h); + assert!(still_waiting(&h, id), "{key}"); + assert_eq!(h.state().ui.status, "", "{key} presses nothing"); + } + + for key in ["Enter", "Space"] { + h.get_by_label("OK").focus(); + h.run_steps(2); + assert!(h.get_by_label("OK").accesskit_node().is_focused()); + h.state_mut().ui.status.clear(); + let r = request(h.state_mut(), &tx, "ui.key", json!({"key": key})); + assert_eq!(r["ok"], true, "{r}"); + take_injected(&mut h); + assert!(still_waiting(&h, id), "{key} on a focused OK"); + assert!(h.state().ui.status.contains(refusal), "{key} pressed OK and was refused: {:?}", h.state().ui.status); + } + + let at = h.get_by_label("OK").rect().center(); + h.state_mut().ui.status.clear(); + let r = request(h.state_mut(), &tx, "ui.click", json!({"x": at.x, "y": at.y})); + assert_eq!(r["ok"], true, "{r}"); + take_injected(&mut h); + assert!(still_waiting(&h, id), "ui.click"); + assert!(h.state().ui.status.contains(refusal), "ui.click pressed OK and was refused: {:?}", h.state().ui.status); + + let events = json!([{"kind": "down", "x": at.x, "y": at.y}, {"kind": "up", "x": at.x, "y": at.y}]); + let r = request(h.state_mut(), &tx, "ui.pointer", json!({"events": events})); + assert_eq!(r["ok"], true, "{r}"); + take_injected(&mut h); + assert!(still_waiting(&h, id), "ui.pointer"); + + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().ui.dialog.is_none(), "{:?}", h.state().ui.status); + assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(vectorcraft_plugins::permissions::sha256_hex(&bytes))); + h.state_mut().run("plugin.remove", json!({"id": id})).unwrap(); +} + /// Edit Original and the Attributes panel's Browser button hand a file or URL to another app: a /// control request (or MCP, which comes through it) can't run them, roots or not. #[test] diff --git a/crates/ui-egui/src/tests/document_drag.rs b/crates/ui-egui/src/tests/document_drag.rs index b5727eaad..f01314a66 100644 --- a/crates/ui-egui/src/tests/document_drag.rs +++ b/crates/ui-egui/src/tests/document_drag.rs @@ -4,6 +4,7 @@ use egui::{Event, PointerButton, Pos2}; use serde_json::json; use vectorcraft_engine::Session; use vectorcraft_geom::Point; +use vectorcraft_tools::{PointerEvent, PointerKind}; use crate::{VectorcraftApp, canvas, chrome}; @@ -142,6 +143,57 @@ fn drag_fixture_modifiers(modifiers: egui::Modifiers) -> (VectorcraftApp, egui:: (app, ctx, grab, tabs) } +/// A Cmd-press held while a Shape Builder merge is computed reaches the lent Selection tool once +/// the merge is applied, and its drag carries the artwork over another tab. +#[test] +fn a_cmd_drag_held_during_a_merge_activates_another_tab() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":800,"height":200,"name":"Source"})).unwrap(); + for k in 0..40 { + app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap(); + } + app.run("select.all", json!({})).unwrap(); + app.run("file.new", json!({"width":800,"height":200,"name":"Target"})).unwrap(); + app.session.set_active(0); + app.select_tool("shapeBuilder"); + app.ui.task_bar = false; + app.ui.view.rulers = false; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + app.ui_ctx = Some(ctx.clone()); + frame(&mut app, &ctx, 0.0, vec![]); + let tabs = frame(&mut app, &ctx, 0.01, vec![]); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Move, 15.0, 10.0), view).unwrap(); + app.session.tool_poll(view, true).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 15.0, 10.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Up, 15.0, 10.0), view).unwrap(); + assert!(app.session.tool_editing(), "the click starts a merge on another thread"); + let grab = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(355.0, 10.0)); + let modifiers = egui::Modifiers::COMMAND; + frame( + &mut app, + &ctx, + 0.05, + vec![ + Event::ModifiersChanged(modifiers), + Event::PointerMoved(grab), + Event::PointerButton { pos: grab, button: PointerButton::Primary, pressed: true, modifiers }, + ], + ); + assert_eq!(app.session.tool_id(), "shapeBuilder", "the press waits for the merge"); + let r = app.session.tool_poll(view, true); + canvas::apply_requests(&mut app, r); + frame(&mut app, &ctx, 0.08, vec![]); + assert_eq!(app.session.tool_id(), "selection", "the held press borrowed the Selection tool"); + frame(&mut app, &ctx, 0.1, vec![Event::PointerMoved(grab + egui::vec2(20.0, 10.0))]); + let tab = tabs[1].center(); + frame(&mut app, &ctx, 0.2, vec![Event::PointerMoved(tab)]); + frame(&mut app, &ctx, 0.25, vec![]); + frame(&mut app, &ctx, 0.8, vec![]); + assert_eq!(app.session.active_index(), Some(1), "holding the artwork over the tab activates it"); +} + /// Verify that leaving a tab restarts its dwell timer and hovering its close button never activates it. #[test] fn tab_hover_delay_restarts_after_leaving_and_close_button_does_not_activate() { diff --git a/crates/ui-egui/src/tests/nativeoptions.rs b/crates/ui-egui/src/tests/nativeoptions.rs index 59b8c91de..5f136d514 100644 --- a/crates/ui-egui/src/tests/nativeoptions.rs +++ b/crates/ui-egui/src/tests/nativeoptions.rs @@ -32,7 +32,7 @@ fn save_as_native_asks_for_its_options_and_writes_every_artboard() { let d = app.ui.dialog.as_ref().unwrap(); assert_eq!( (d.str("format").as_str(), d.bool("separateArtboards"), d.bool("embedProfiles"), d.bool("pdfCompatible")), - ("vectorcraft", false, true, false) + ("vectorcraft", false, true, true) ); assert!(written.borrow().is_empty(), "nothing is written before OK"); frame(&mut app); diff --git a/crates/ui-egui/src/tests/numfields.rs b/crates/ui-egui/src/tests/numfields.rs index 8d475a917..d83938555 100644 --- a/crates/ui-egui/src/tests/numfields.rs +++ b/crates/ui-egui/src/tests/numfields.rs @@ -920,11 +920,10 @@ fn wheel(at: Pos2, unit: egui::MouseWheelUnit, dy: f32, modifiers: Modifiers) -> ] } -/// The mouse wheel over a focused numeric field steps it as ↑/↓ do (#485): a notch a step, Shift -/// ten, Ctrl/Cmd a tenth; a trackpad's points add up to notches. Unfocused, or with the pointer -/// elsewhere, the wheel isn't the field's. +/// Hover stepping (#5949) keeps the existing units, modifiers and trackpad accumulation, +/// without taking keyboard focus. Outside the field, the wheel is left alone. #[test] -fn the_wheel_steps_a_focused_field_under_the_pointer() { +fn the_wheel_steps_a_hovered_field_without_taking_focus() { use egui::MouseWheelUnit::{Line, Point}; let mut f = Field::new(); let u = Unit::Millimeters; @@ -932,16 +931,13 @@ fn the_wheel_steps_a_focused_field_under_the_pointer() { let draw = |ui: &mut egui::Ui| widgets::num_field(ui, "f", Some(u.to_pt(mm.get())), u, 80.0).inspect(|&v| mm.set(u.from_pt(v))); f.frame(vec![], &draw); let at = f.rect.get().center(); - assert_eq!(f.frame(wheel(at, Line, 1.0, Modifiers::NONE), &draw), None, "unfocused"); - f.frame(f.click(1), &draw); - f.frame(vec![], &draw); for (dy, mods, want) in [(1.0, Modifiers::NONE, 5.0), (2.0, Modifiers::SHIFT, 25.0), (-1.0, Modifiers::COMMAND, 24.9), (-1.0, Modifiers::NONE, 24.0)] { assert!(f.frame(wheel(at, Line, dy, mods), &draw).is_some(), "{dy} {mods:?}"); assert!((mm.get() - want).abs() < 1e-6, "{dy} {mods:?}: {}", mm.get()); } - assert_eq!(f.selection(), Some((0, "24 mm".len())), "the new value is selected"); + assert!(!f.ctx.memory(|m| m.has_focus(f.id.get())), "hover never takes keyboard focus"); // Points: a step for each line's worth, what is left carried to the next turn. let line = f.ctx.options(|o| o.input_options.line_scroll_speed); assert_eq!(f.frame(wheel(at, Point, line * 0.6, Modifiers::NONE), &draw), None); @@ -957,16 +953,16 @@ fn the_wheel_steps_a_focused_field_under_the_pointer() { assert_eq!(f.frame(wheel(f.rect.get().center(), Line, -1.0, Modifiers::NONE), &count), Some(2.0)); } -/// The wheel over a focused field steps it and leaves the panel where it is; over an unfocused one -/// it scrolls the panel as before. +/// Both focused and unfocused hovered fields own the wheel, including its smoothing tail. #[test] -fn the_wheel_scrolls_the_panel_unless_over_the_focused_field() { +fn the_wheel_scrolls_the_panel_unless_over_a_numeric_field() { for focus in [false, true] { let mut f = Field::new(); let offset = Cell::new(0.0); + let value = Cell::new(10.0); let draw = |ui: &mut egui::Ui| { let out = egui::ScrollArea::vertical().max_height(60.0).show(ui, |ui| { - let v = widgets::plain_field(ui, "f", 10.0, "", 0, 80.0); + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); ui.add_space(600.0); v }); @@ -994,11 +990,59 @@ fn the_wheel_scrolls_the_panel_unless_over_the_focused_field() { for _ in 0..20 { f.frame(vec![], &draw); } - if focus { - assert_eq!((stepped, offset.get()), (Some(8.0), 0.0)); - } else { - assert!(stepped.is_none() && offset.get() > 0.0, "{stepped:?} {}", offset.get()); + assert_eq!((stepped, offset.get()), (Some(8.0), 0.0), "focus {focus}"); + let line = f.ctx.options(|o| o.input_options.line_scroll_speed); + assert_eq!(f.frame(wheel(at, egui::MouseWheelUnit::Point, -line * 0.6, Modifiers::NONE), &draw), None); + assert_eq!(offset.get(), 0.0); + assert_eq!(f.frame(wheel(at, egui::MouseWheelUnit::Point, -line * 0.6, Modifiers::NONE), &draw), Some(7.0)); + for _ in 0..20 { + f.frame(vec![], &draw); } + assert_eq!(offset.get(), 0.0); + assert_eq!(f.frame(wheel(Pos2::new(20.0, 45.0), egui::MouseWheelUnit::Line, -2.0, Modifiers::NONE), &draw), None); + for _ in 0..20 { + f.frame(vec![], &draw); + } + assert!(offset.get() > 0.0, "outside the field scrolls the panel"); + } +} + +/// A wheel turn over an unfocused field steps it without consuming an arrow key in the same frame. +#[test] +fn hover_wheel_keeps_arrow_keys_for_the_keyboard_focus() { + let mut f = Field::new(); + let arrows = Cell::new(false); + let draw = |ui: &mut egui::Ui| { + let v = widgets::plain_field(ui, "f", 1.123, "", 3, 80.0); + arrows.set(ui.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { key: Key::ArrowUp, .. })))); + v + }; + f.frame(vec![], &draw); + let at = f.rect.get().center(); + assert_eq!(f.frame(vec![Event::PointerMoved(at), key(Key::ArrowUp, Modifiers::NONE)], &draw), None); + assert!(arrows.get()); + let mut events = wheel(at, egui::MouseWheelUnit::Line, 1.0, Modifiers::NONE); + events.push(key(Key::ArrowUp, Modifiers::NONE)); + assert_eq!(f.frame(events, &draw), Some(2.0), "existing next-multiple rounding, wheel only"); + assert!(arrows.get(), "hover does not consume arrows"); + assert!(!f.ctx.memory(|m| m.has_focus(f.id.get()))); +} + +/// Hover steps stop at both bounds, leave mixed values blank, and never edit disabled fields. +#[test] +fn hover_wheel_preserves_limits_mixed_values_and_disabled_fields() { + for (value, enabled, dy, want) in [ + (Some(0.5), true, -1.0, Some(0.0)), + (Some(0.0), true, -1.0, None), + (Some(9.5), true, 1.0, Some(10.0)), + (Some(10.0), true, -1.0, Some(9.0)), + (None, true, -1.0, None), + (Some(10.0), false, -1.0, None), + ] { + let mut f = Field::new(); + let draw = |ui: &mut egui::Ui| ui.add_enabled_ui(enabled, |ui| widgets::range_field(ui, "f", value, 0.0..=10.0, "", 3, 80.0)).inner; + f.frame(vec![], &draw); + assert_eq!(f.frame(wheel(f.rect.get().center(), egui::MouseWheelUnit::Line, dy, Modifiers::NONE), &draw), want); } } diff --git a/crates/ui-egui/src/tests/picks.rs b/crates/ui-egui/src/tests/picks.rs index fdd0798a8..c1e3f822c 100644 --- a/crates/ui-egui/src/tests/picks.rs +++ b/crates/ui-egui/src/tests/picks.rs @@ -417,6 +417,7 @@ fn keyboard_shortcuts_export_text_writes_the_working_set_as_text() { Ok(()) })); let ov = crate::keymap::overrides_from(&[("view.grid", "Ctrl+Alt+G")]); + let revision = crate::menus::plugin_revision(); app.run("edit.keyboardShortcuts", json!({})).unwrap(); let d = app.ui.dialog.as_mut().expect("Keyboard Shortcuts"); d.fields.insert("overrides".into(), crate::keymap::stored::to_value(&ov)); @@ -431,7 +432,11 @@ fn keyboard_shortcuts_export_text_writes_the_working_set_as_text() { let texts = texts.borrow(); let [(path, text)] = texts.as_slice() else { panic!("{texts:?}") }; assert_eq!(path, "/keys/list.txt"); - assert_eq!(*text, crate::shortcut_editor::export_text("Custom", &ov)); + // Another test installing or removing a plug-in meanwhile changes the Effect menu's rows. + if crate::menus::plugin_revision() == revision { + assert_eq!(*text, crate::shortcut_editor::export_text("Custom", &ov)); + } + assert!(text.starts_with("Keyboard Shortcuts\nSet: Custom\n"), "{text}"); assert!(text.contains(&format!("\t{}\n", crate::menus::pretty_shortcut("Ctrl+Alt+G"))), "{text}"); assert!(app.ui.shortcut_overrides.is_empty(), "the working set is kept only on OK"); assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("shortcuts"), "the dialog stays open"); diff --git a/crates/ui-egui/src/tests/plugin_runs.rs b/crates/ui-egui/src/tests/plugin_runs.rs index 194ec133d..c3bd9df30 100644 --- a/crates/ui-egui/src/tests/plugin_runs.rs +++ b/crates/ui-egui/src/tests/plugin_runs.rs @@ -13,6 +13,7 @@ use crate::{VectorcraftApp, dialogs, plugin_runs, theme}; /// An app with a new 200 × 200 pt document. fn app() -> VectorcraftApp { let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.allow_plugin_grants_from_requests(); app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); app } @@ -71,10 +72,10 @@ fn installing_a_heavy_filter_asks_first() { let id = "test.ui.heavy.install"; let bytes = heavy(id, 1, "{}"); let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": "route.wasm"})).unwrap(); - assert_eq!(r["pending"], dialogs::confirm::KIND); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!(d.str("message"), format!("Install “Test {id}”, which asks for a heavy budget?")); - assert!(d.str("detail").contains("up to 5 minutes and do 20 times the work"), "{}", d.str("detail")); + assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND); + let pending = app.pending_plugin_install.as_ref().unwrap(); + assert_eq!(pending.message, format!("Install “Test {id}”, which asks for a heavy budget?")); + assert!(pending.detail.contains("up to 5 minutes and do 20 times the work"), "{}", pending.detail); assert!(registry::get(id).is_none(), "not before the user agrees"); dialogs::confirm(&mut app).unwrap(); assert_eq!(registry::get(id).map(|p| p.manifest().name.clone()), Some(format!("Test {id}"))); @@ -82,7 +83,7 @@ fn installing_a_heavy_filter_asks_first() { // File › Open of the .wasm asks too, and Cancel installs nothing. registry::remove(id); crate::io::open_bytes(&mut app, "route.wasm", &bytes, None).unwrap(); - assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::KIND)); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::plugin::INSTALL_KIND)); dialogs::cancel(&mut app); assert!(registry::get(id).is_none()); } diff --git a/crates/ui-egui/src/tests/plugins.rs b/crates/ui-egui/src/tests/plugins.rs index d5e10d8d1..22cd5caff 100644 --- a/crates/ui-egui/src/tests/plugins.rs +++ b/crates/ui-egui/src/tests/plugins.rs @@ -130,8 +130,9 @@ fn asking(id: &str, name: &str, permissions: &str) -> Vec { /// Installing a plug-in that lists permissions asks first, showing its name, id, version, file hash /// and each permission in plain words, with hidden characters from the manifest escaped. Cancel -/// installs nothing, OK installs it; installing it again with the same permissions doesn't ask, and -/// one that asks for another does. +/// installs nothing, OK installs it; installing the same module again doesn't ask, one with other +/// bytes asks again, even when it lists the same permissions, and in the same dialog whether to +/// take the installed one's place, as does one that asks for another permission. #[test] fn installing_a_plugin_that_lists_permissions_asks_first() { let mut app = app(); @@ -142,39 +143,68 @@ fn installing_a_plugin_that_lists_permissions_asks_first() { let bytes = asking(id, name, r#"["doc.read","doc.write"]"#); let data = vectorcraft_format::base64_encode(&bytes); let r = app.run("ui.installPlugin", json!({"dataBase64": data, "name": "tidy.wasm"})).unwrap(); - assert_eq!(r["pending"], dialogs::confirm::KIND); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!(d.str("message"), "Install “Tidy\\u{202E}fdp.exe\\u{2066}”, which asks for these permissions?"); + assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND); + let pending = app.pending_plugin_install.as_ref().unwrap(); + assert_eq!(pending.message, "Install “Tidy\\u{202E}fdp.exe\\u{2066}”, which asks for these permissions?"); let hash = vectorcraft_plugins::permissions::sha256_hex(&bytes); - assert_eq!(d.fields.get("__params").map(|p| p["hash"].clone()), Some(json!(hash)), "OK installs the module shown"); - let detail = d.str("detail"); + assert_eq!(pending.params["hash"], json!(hash), "OK installs the module shown"); + let detail = pending.detail.clone(); assert_eq!( detail, format!( "ID: {id}\nVersion: 3.0\\u{{202E}}\\u{{0009}}\\u{{0007}}\nFile SHA-256: {hash}\n\n• Read the objects it is given\n• Change, add and delete objects in the document" ) ); - assert!(!detail.chars().chain(d.str("message").chars()).any(|c| matches!(c, '\u{202E}' | '\u{2066}' | '\t' | '\u{0007}'))); + assert!(!detail.chars().chain(pending.message.chars()).any(|c| matches!(c, '\u{202E}' | '\u{2066}' | '\t' | '\u{0007}'))); assert!(vectorcraft_plugins::registry::get(id).is_none(), "not before the user agrees"); dialogs::cancel(&mut app); assert!(vectorcraft_plugins::registry::get(id).is_none(), "Cancel installs nothing"); + assert!(app.pending_plugin_install.is_none(), "Cancel drops what it asked about"); // File › Open asks too, and OK installs it with what it lists granted. io::open_bytes(&mut app, "tidy.wasm", &bytes, None).unwrap(); - assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::KIND)); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::plugin::INSTALL_KIND)); dialogs::confirm(&mut app).unwrap(); assert!(app.ui.dialog.is_none()); assert!(vectorcraft_plugins::registry::get(id).is_some()); assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(hash)); - // Again with the same permissions: installed without asking. + // The same module again: installed without asking. + app.run("ui.installPlugin", json!({"dataBase64": data})).unwrap(); + assert!(app.ui.dialog.is_none(), "the same bytes don't ask again"); + // Other bytes with the same permissions ask for them, from both ways in; Cancel keeps the one + // installed. let again = asking(id, "Tidy 2", r#"["doc.write","doc.read"]"#); + let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap(); + assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND, "other bytes ask again"); + assert_eq!(app.pending_plugin_install.as_ref().unwrap().message, "Install “Tidy 2”, which asks for these permissions?"); + dialogs::cancel(&mut app); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some(name.into()), "Cancel keeps the one installed"); + io::open_bytes(&mut app, "tidy2.wasm", &again, None).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::plugin::INSTALL_KIND), "File › Open asks too"); + // The same dialog says it takes the installed one's place, and OK replaces it; Cancel keeps the + // one installed and records no grant. + let pending = app.pending_plugin_install.as_ref().unwrap(); + let again_hash = vectorcraft_plugins::permissions::sha256_hex(&again); + assert!(pending.detail.contains(&format!("File SHA-256: {again_hash}")), "{}", pending.detail); + assert!( + pending.detail.ends_with("\n\nThis file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID."), + "{}", + pending.detail + ); + assert_eq!(pending.params["replace"], json!(true), "OK replaces the one installed"); + dialogs::cancel(&mut app); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some(name.into()), "Cancel keeps the one installed"); + assert_ne!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(again_hash), "Cancel records no grant"); app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap(); - assert!(app.ui.dialog.is_none(), "the same permissions don't ask again"); + dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "one question"); assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into())); + let new_hash = vectorcraft_plugins::permissions::sha256_hex(&again); + assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(new_hash), "OK records the new module's hash"); // One that asks for another permission asks again. let more = asking(id, "Tidy 3", r#"["doc.read","doc.write","state"]"#); let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&more)})).unwrap(); - assert_eq!(r["pending"], dialogs::confirm::KIND); - assert!(app.ui.dialog.as_ref().unwrap().str("detail").ends_with("• Keep data of its own with the document")); + assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND); + assert!(app.pending_plugin_install.as_ref().unwrap().detail.contains("• Keep data of its own with the document")); dialogs::cancel(&mut app); assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into()), "Cancel keeps the one installed"); // OK keeps the `replace` it was asked with: `false` leaves the installed one in place. @@ -184,6 +214,185 @@ fn installing_a_plugin_that_lists_permissions_asks_first() { app.run("plugin.remove", json!({"id": id})).unwrap(); } +/// A module with other bytes than the one installed with its id asks before taking its place: in +/// the ordinary confirm when it needs nothing that only a person can approve, and in the approval +/// dialog, with its id and file hash, when it also asks for a heavy budget. +#[test] +fn installing_a_plugin_over_another_module_asks_first() { + let mut app = app(); + let id = "test.ui.replaces"; + let plain = |output: &str| wat::parse_str(tpl::returning(&format!(r#"{{"id":"{id}","name":"Plain","kind":"filter"}}"#), output)).unwrap(); + let module = |wasm: &[u8]| json!({"dataBase64": vectorcraft_format::base64_encode(wasm)}); + let takes_place = "This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID."; + app.run("ui.installPlugin", module(&plain(r#"{"objects":[]}"#))).unwrap(); + assert!(app.ui.dialog.is_none(), "nothing to take the place of"); + + let other = plain(r#"{"objects":[],"error":null}"#); + let other_hash = vectorcraft_plugins::permissions::sha256_hex(&other); + let r = app.run("ui.installPlugin", module(&other)).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::KIND); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.str("message"), "Install “Plain” in place of the plug-in with the same ID?"); + assert_eq!(d.str("detail"), format!("ID: {id}\nFile SHA-256: {other_hash}\n\n{takes_place}")); + dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string()), Some(other_hash)); + + let manifest = format!(r#"{{"id":"{id}","name":"Heavy","kind":"filter","budget":"heavy"}}"#); + let heavy = wat::parse_str(tpl::spinning(&manifest, 1, r#"{"objects":[]}"#)).unwrap(); + let heavy_hash = vectorcraft_plugins::permissions::sha256_hex(&heavy); + let r = app.run("ui.installPlugin", module(&heavy)).unwrap(); + assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND); + let pending = app.pending_plugin_install.as_ref().unwrap(); + assert!(pending.detail.ends_with(&format!("\n\nID: {id}\nFile SHA-256: {heavy_hash}\n\n{takes_place}")), "{}", pending.detail); + assert_eq!(pending.params["replace"], json!(true), "OK replaces the one installed"); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string()), Some(heavy_hash)); + app.run("plugin.remove", json!({"id": id})).unwrap(); +} + +/// A plug-in with two commands: Grow, which adds a triangle (`_context.command` is `grow`), and +/// Size, with a parameter, whose `menu` names another menu. +fn commands_plugin(id: &str) -> Vec { + let manifest = format!( + r#"{{"id":"{id}","name":"Cmd Test","version":"1","kind":"filter","commands":[{{"id":"grow","label":"Grow"}}, + {{"id":"size","label":"Size","menu":"File","params":{{"by":{{"type":"number","min":0,"max":10,"default":2}}}}}}]}}"# + ); + let triangle = r#"{"objects":[{"path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[5,0]},{"p":[5,5]}]}]}}]}"#; + wat::parse_str(tpl::when_given(&manifest, tpl::Given::Params, r#""command":"grow""#, triangle)).unwrap() +} + +fn objects(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum() +} + +/// A plug-in's commands are items of Object › Plug-ins › its name (never the menu a manifest +/// names), entries of Edit › Keyboard Shortcuts with no shortcut of their own, and gone from both +/// once it is removed. A command with parameters opens its dialog. +#[test] +fn plugin_commands_are_menu_items_and_shortcut_entries() { + let mut app = app(); + let id = "test.ui.cmds"; + let (grow, size) = ("plugin.test.ui.cmds.grow", "plugin.test.ui.cmds.size"); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&commands_plugin(id))})).unwrap(); + let entries = menus::menu_entries(&app); + let under = ["Object", "Plug-ins", "Cmd Test"]; + let item = entry(&entries, &under, "Grow").expect("Grow is listed"); + assert_eq!((item.command.as_deref(), item.enabled), (Some(grow), true)); + assert!( + entry(&entries, &under, "Size…").is_some(), + "Size is listed: {:?}", + entries.iter().filter(|e| e.path == under).map(|e| &e.label).collect::>() + ); + assert!(!entries.iter().any(|e| e.command.as_deref() == Some(size) && e.path.first().map(String::as_str) != Some("Object")), "not in File"); + assert!(!menus::menu_strings().contains("Cmd Test"), "the plug-in's name is not text to translate"); + + let listed = crate::shortcut_editor::entry(grow).expect("in Keyboard Shortcuts"); + assert_eq!((listed.label.as_str(), listed.group.as_str()), ("Grow", "Object › Plug-ins › Cmd Test")); + assert_eq!(crate::shortcut_editor::default_of(grow), None, "a manifest gives no shortcut"); + assert!(crate::keymap::template().bindable(&crate::keymap::command_of(grow)).is_some(), "a shortcut can be given to it"); + + // Size asks for its parameter; OK runs it as one undo step named after it. + let undo = app.session.doc().unwrap().history.undo.len(); + menus::invoke(&mut app, size, json!({})); + let d = app.ui.dialog.as_ref().expect("Size opens its dialog"); + assert_eq!((d.str("__command"), d.str("__label"), d.fields.get("by")), (size.to_string(), "Size".to_string(), Some(&json!(2.0)))); + frame(&mut app); + dialogs::confirm(&mut app).unwrap(); + let st = app.session.doc().unwrap(); + assert_eq!((st.history.undo.len(), st.history.undo.last().unwrap().label.as_str()), (undo + 1, "Size")); + + app.run("plugin.remove", json!({"id": id})).unwrap(); + assert!(menus::menu_entries(&app).iter().all(|e| e.command.as_deref() != Some(grow))); + assert!(crate::shortcut_editor::entries().iter().all(|e| e.key != grow)); + assert!(crate::keymap::template().bindable(&crate::keymap::command_of(grow)).is_none()); +} + +/// A shortcut the user gives a plug-in command runs it. +#[test] +fn a_shortcut_runs_a_plugin_command() { + let mut app = app(); + let id = "test.ui.cmds.keys"; + let grow = vectorcraft_engine::cmd::plugin::command_id(id, "grow"); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&commands_plugin(id))})).unwrap(); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, &grow, "Cmd+Alt+Shift+F9"); + crate::shortcut_editor::sync(&app.ui); + assert_eq!(crate::shortcut_editor::command_for_key("Cmd+Alt+Shift+F9"), Some(grow.as_str())); + let before = objects(&app); + let m = if crate::keymap::platform().is_mac() { egui::Modifiers::MAC_CMD } else { egui::Modifiers::CTRL } | egui::Modifiers::COMMAND; + let m = m | egui::Modifiers::ALT | egui::Modifiers::SHIFT; + let events = vec![ + egui::Event::ModifiersChanged(m), + egui::Event::Key { key: egui::Key::F9, physical_key: Some(egui::Key::F9), pressed: true, repeat: false, modifiers: m }, + ]; + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + app.logic(ui.ctx()); + crate::shortcuts::handle(&mut app, ui.ctx()); + }); + out.textures_delta.clear(); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + assert_eq!(objects(&app), before + 1, "the command added its triangle: {}", app.ui.status); + app.run("plugin.remove", json!({"id": id})).unwrap(); +} + +/// The `grant` and `allowHeavy` that `ui.installPlugin` is given approve nothing: it asks all the +/// same, and what OK would approve comes from the manifest. A module with nothing to approve +/// installs without either. +#[test] +fn ui_install_plugin_takes_no_grant_or_heavy_budget_from_its_params() { + let mut app = app(); + let id = "test.ui.approval.params"; + let manifest = format!(r#"{{"id":"{id}","name":"Asks","kind":"filter","permissions":["doc.read"]}}"#); + let asks = wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap(); + let given = |wasm: &[u8]| json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "grant": ["doc.read", "doc.write"], "allowHeavy": true}); + let r = app.run("ui.installPlugin", given(&asks)).unwrap(); + assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND); + assert!(vectorcraft_plugins::registry::get(id).is_none() && vectorcraft_plugins::registry::granted(id).is_none()); + let pending = app.pending_plugin_install.as_ref().unwrap(); + assert_eq!(pending.approval.permissions, ["doc.read"], "the permissions the manifest lists"); + assert_eq!(pending.params["grant"], json!(["doc.read"])); + assert!(!pending.approval.heavy && pending.params.get("allowHeavy").is_none(), "no heavy budget was asked about"); + dialogs::cancel(&mut app); + assert!(vectorcraft_plugins::registry::get(id).is_none()); + + let heavy_id = "test.ui.approval.params.heavy"; + let manifest = format!(r#"{{"id":"{heavy_id}","name":"Heavy","kind":"filter","budget":"heavy"}}"#); + let heavy = wat::parse_str(tpl::spinning(&manifest, 1, r#"{"objects":[]}"#)).unwrap(); + let r = app.run("ui.installPlugin", given(&heavy)).unwrap(); + assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND, "allowHeavy skips no question"); + assert!(vectorcraft_plugins::registry::get(heavy_id).is_none()); + dialogs::cancel(&mut app); + + let plain_id = "test.ui.approval.params.plain"; + let manifest = format!(r#"{{"id":"{plain_id}","name":"Plain","kind":"filter"}}"#); + let plain = wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap(); + let r = app.run("ui.installPlugin", given(&plain)).unwrap(); + assert!(r.get("pending").is_none() && app.ui.dialog.is_none(), "{r}"); + assert!(vectorcraft_plugins::registry::get(plain_id).is_some()); + assert!(vectorcraft_plugins::registry::granted(plain_id).is_none(), "the grant it was given isn't recorded"); + app.run("plugin.remove", json!({"id": plain_id})).unwrap(); +} + +/// An action's step can't grant what only a person approves: a played `plugin.install` with `grant` +/// is refused at Accelerated and at Step by Step, and nothing is installed. +#[test] +fn an_action_step_cannot_grant_a_plugin_its_permissions() { + use crate::panels::actions::{Action, ActionSet, PlaybackSpeed, play}; + let mut app = app(); + let id = "test.ui.approval.action"; + let manifest = format!(r#"{{"id":"{id}","name":"Asks","kind":"filter","permissions":["doc.read"]}}"#); + let wasm = wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap(); + let step = json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]}); + app.ui.action_sets = vec![ActionSet::new("Test", vec![Action::new("Install", vec![("plugin.install".into(), step)])])]; + for speed in [PlaybackSpeed::Accelerated, PlaybackSpeed::Step] { + app.ui.action_playback.speed = speed; + let e = play(&mut app, 0, 0).unwrap_err(); + assert!(e.contains("take effect only in a session started with --allow-plugin-grants"), "{e}"); + assert!(vectorcraft_plugins::registry::get(id).is_none() && vectorcraft_plugins::registry::granted(id).is_none()); + } +} + /// A plug-in of API level 2 that lists `progress` names it in plain words when it asks. #[test] fn installing_a_plugin_that_reports_progress_says_so() { @@ -192,8 +401,8 @@ fn installing_a_plugin_that_reports_progress_says_so() { let manifest = format!(r#"{{"id":"{id}","name":"Count","kind":"filter","api":2,"permissions":["doc.read","progress"]}}"#); let bytes = wat::parse_str(tpl::reporting(&manifest, "(call $vc_progress (i64.const 1) (i64.const 2))", r#"{"objects":[]}"#)).unwrap(); let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); - assert_eq!(r["pending"], dialogs::confirm::KIND); - let detail = app.ui.dialog.as_ref().unwrap().str("detail"); + assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND); + let detail = app.pending_plugin_install.as_ref().unwrap().detail.clone(); assert!(detail.ends_with("• Read the objects it is given\n• Show its progress, see Cancel and write log lines"), "{detail}"); dialogs::confirm(&mut app).unwrap(); assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.permissions.len()), Some(2)); diff --git a/crates/ui-egui/src/tests/removeanchors.rs b/crates/ui-egui/src/tests/removeanchors.rs index a809f174d..44bf492cc 100644 --- a/crates/ui-egui/src/tests/removeanchors.rs +++ b/crates/ui-egui/src/tests/removeanchors.rs @@ -242,6 +242,7 @@ fn anchor_delete_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Ve out.textures_delta.clear(); } +/// Real canvas picking and both deletion keys split an ordinary path, then clear every survivor. #[test] fn open_anchor_delete_and_backspace_cut_the_clicked_anchor_then_clear() { for key in [egui::Key::Delete, egui::Key::Backspace] { @@ -279,15 +280,23 @@ fn open_anchor_delete_and_backspace_cut_the_clicked_anchor_then_clear() { anchor_delete_frame(&mut app, &ctx, vec![press(true)]); anchor_delete_frame(&mut app, &ctx, vec![press(false)]); let st = app.session.doc().unwrap(); - let path = st.doc.node(id).unwrap().path_data().unwrap(); - let points: Vec> = path.subpaths.iter().map(|sp| sp.anchors.iter().map(|a| a.p).collect()).collect(); + let pieces = st.selection.objects.clone(); + assert_eq!(pieces.len(), 2); + assert_eq!(pieces[0], id); + let points: Vec> = pieces + .iter() + .map(|piece| { + let path = st.doc.node(*piece).unwrap().path_data().unwrap(); + assert_eq!(path.subpaths.len(), 1); + assert!(!path.subpaths[0].closed); + path.subpaths[0].anchors.iter().map(|a| a.p).collect() + }) + .collect(); assert_eq!( points, vec![vec![Point::new(50.0, 100.0), Point::new(100.0, 80.0)], vec![Point::new(200.0, 120.0), Point::new(250.0, 100.0)]], "{key:?} must not join the gap" ); - assert!(path.subpaths.iter().all(|sp| !sp.closed)); - assert_eq!(st.selection.objects, vec![id]); assert!(st.selection.anchors.is_empty() && st.selection.segments.is_empty()); assert_eq!(st.history.undo.len(), undo + 1); let after = st.doc.clone(); @@ -296,8 +305,9 @@ fn open_anchor_delete_and_backspace_cut_the_clicked_anchor_then_clear() { assert_eq!(app.session.doc().unwrap().selection, selection); app.run("edit.redo", json!({})).unwrap(); assert_eq!(app.session.doc().unwrap().doc, after); + assert_eq!(app.session.doc().unwrap().selection.objects, pieces); anchor_delete_frame(&mut app, &ctx, vec![press(true)]); anchor_delete_frame(&mut app, &ctx, vec![press(false)]); - assert!(app.session.doc().unwrap().doc.node(id).is_none(), "the next {key:?} clears the whole remainder"); + assert!(pieces.iter().all(|piece| app.session.doc().unwrap().doc.node(*piece).is_none()), "the next {key:?} clears every piece"); } } diff --git a/crates/ui-egui/src/tests/rotateview.rs b/crates/ui-egui/src/tests/rotateview.rs index fb434fd2a..8f477894a 100644 --- a/crates/ui-egui/src/tests/rotateview.rs +++ b/crates/ui-egui/src/tests/rotateview.rs @@ -227,7 +227,7 @@ impl Window { } /// One headless frame of the whole window, `seconds` after the last one, with `events`. - fn after(&mut self, seconds: f64, events: Vec) { + fn after(&mut self, seconds: f64, events: Vec) -> egui::FullOutput { self.time += seconds; let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); let raw = egui::RawInput { screen_rect: Some(rect), time: Some(self.time), events, ..Default::default() }; @@ -237,6 +237,7 @@ impl Window { app.ui(ui); }); out.textures_delta.clear(); + out } /// A press and release of Escape, `seconds` after the last frame. @@ -336,12 +337,18 @@ fn escape_after_ending_isolation_or_with_the_rotate_view_tool_resets_the_view() #[test] fn escape_after_closing_a_popup_or_during_a_ruler_origin_drag_keeps_the_rotation() { let mut w = Window::new(); - // The menu's button has the id `ComboBox::from_id_salt("rot-combo")` gives it in the status bar's - // row, which hosts other widgets too. egui 0.37 salts the Ui's stable `scope_id`, not the - // widget rect's unstable `parent_id`; in debug builds the id's debug source names its salt. - let widgets: Vec = w.ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, ws)| ws.iter().copied()).collect()); - let combo = widgets.iter().find(|r| format!("{:?}", r.id).contains("rot-combo")).copied(); - let combo = combo.expect("the status bar's angle menu"); + // Find the actual combo by its accessible name, which is present in both build profiles; + // `Id`'s debug source (including "rot-combo") only exists with debug assertions enabled. + w.ctx.enable_accesskit(); + let out = w.after(0.0, vec![]); + let tree = out.platform_output.accesskit_update.expect("the window's accessibility tree"); + let combos: Vec<_> = + tree.nodes.iter().filter(|(_, node)| node.role() == egui::accesskit::Role::ComboBox && node.label() == Some("Rotation")).collect(); + assert_eq!(combos.len(), 1, "the status bar's angle menu"); + let combo = w + .ctx + .viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, ws)| ws.iter()).find(|r| r.id.accesskit_id() == combos[0].0).copied()) + .expect("the angle menu's widget"); w.click(0.0, egui::PointerButton::Primary, combo.rect.center()); w.after(0.0, vec![]); assert!(craft_ui::ComboBox::is_open(&w.ctx, combo.id), "the angle menu is open"); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index be04485d0..6a90cdfac 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -16,7 +16,7 @@ use crate::theme::{self, Tokens}; use crate::{VectorcraftApp, icons, widgets}; pub(crate) const PITCH: f32 = 30.0; -const WIDTH: f32 = 48.0; +pub(crate) const WIDTH: f32 = 48.0; /// Seconds a press on a tool group's button is held before its flyout opens. const LONG_PRESS: f64 = 0.35; /// Width of the grab bar down a flyout's right side: dragging it tears the flyout off. @@ -95,24 +95,73 @@ pub fn remember(app: &mut VectorcraftApp, id: &str) { crate::custom_toolbars::remember(&mut app.ui, id); } +/// The docked Tools panel's rect as last laid out; removed while it floats. +pub(crate) fn docked_rect_id() -> egui::Id { + egui::Id::unique("toolbar-docked-rect") +} + +fn panel_width(ui: &crate::state::UiState) -> f32 { + if ui.toolbar_double { 80.0 } else { WIDTH } +} + +/// Space reserved inside the protected canvas panel for a visible Tools panel docked beside the +/// canvas (`UiState::tools_beside_canvas`) or on the canvas's right (`UiState::toolbar_right`); +/// none while Tools docks at the window's left edge. +pub(crate) fn docked_width(ui: &crate::state::UiState) -> f32 { + if (ui.tools_beside_canvas || ui.toolbar_right) && ui.toolbar && ui.screen_mode < 3 && ui.toolbar_pos.is_none() { + panel_width(ui) + 6.0 + } else { + 0.0 + } +} + +/// With `UiState::tools_beside_canvas` on, the canvas-edge redocking target, registered even +/// while Tools floats, and the docked panel, which follows the canvas's left edge as dock columns +/// resize, collapse or hide. With it off, `app.rs` draws Tools before the docking layout. +/// +/// The canvas's right edge, left of the panels, is a redocking target too, and Tools docked there +/// (`UiState::toolbar_right`) is drawn at it whichever way the setting is. +pub(crate) fn show_beside_canvas(app: &mut VectorcraftApp, ui: &mut Ui) { + if !app.ui.toolbar || app.ui.screen_mode >= 3 { + return; + } + let canvas = ui.available_rect_before_wrap(); + crate::tools_float::set_right_dock_zone(ui.ctx(), canvas, WIDTH); + if app.ui.tools_beside_canvas { + crate::tools_float::set_dock_zones(ui.ctx(), canvas, WIDTH); + } + if app.ui.toolbar_pos.is_none() && (app.ui.tools_beside_canvas || app.ui.toolbar_right) { + show(app, ui); + } +} + +/// Is the Tools panel docked on the right, between the canvas and the panels? +pub(crate) fn docked_right(ui: &crate::state::UiState) -> bool { + ui.toolbar_right && ui.toolbar_pos.is_none() +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); // The user's choice alone: a window too short for the tools scrolls them. let cols = if app.ui.toolbar_double { 2 } else { 1 }; // Two 36 pt buttons with 2 pt either side inside the border and separator line (at 76 they // overran the right border). - let w = if cols == 2 { 80.0 } else { WIDTH }; - // Where the panel docks: along the window's left edge, under the bars. - let edge = ui.available_rect_before_wrap(); - ui.ctx().data_mut(|d| d.insert_temp(crate::tools_float::tools_zone_id(), egui::Rect::from_min_size(edge.min, vec2(WIDTH, edge.height())))); + let w = panel_width(&app.ui); match app.ui.toolbar_pos { None => { - let margin = egui::Margin { left: 6, right: 0, top: 6, bottom: 6 }; - egui::Panel::left("toolbar").resizable(false).exact_size(w + 6.0).frame(egui::Frame::NONE).show(ui, |ui| { + // The card's outer gap faces the edge it docks at. + let (panel, margin) = if app.ui.toolbar_right { + (egui::Panel::right("toolbar"), egui::Margin { left: 0, right: 6, top: 6, bottom: 6 }) + } else { + (egui::Panel::left("toolbar"), egui::Margin { left: 6, right: 0, top: 6, bottom: 6 }) + }; + let docked = panel.resizable(false).exact_size(w + 6.0).frame(egui::Frame::NONE).show(ui, |ui| { crate::theme::card(ui, &t, margin, |ui| body(app, ui, cols, None)); }); + ui.ctx().data_mut(|d| d.insert_temp(docked_rect_id(), docked.response.rect)); } Some(pos) => { + ui.ctx().data_mut(|d| d.remove::(docked_rect_id())); // Floating, kept inside the window, its tools scrolling when they don't fit under it. let ctx = ui.ctx().clone(); let id = egui::Id::unique("toolbar-floating"); @@ -185,7 +234,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option Option { ctx.input(|i| i.pointer.press_origin().zip(i.pointer.interact_pos()).map(|(a, b)| b - a)) } +/// Where the toolbar's flyouts and drawer may open: the window, or with the toolbar docked right +/// the part left of it, so they open toward the canvas instead of over the panels. +pub(crate) fn room(app: &VectorcraftApp, ctx: &egui::Context) -> egui::Rect { + let mut r = ctx.content_rect(); + if docked_right(&app.ui) + && let Some(x) = ctx.data(|d| d.get_temp::(docked_rect_id())).map(|bar| bar.right()) + { + r.max.x = r.max.x.min(x); + } + r +} + +/// The left edge of something `width` wide opened beside `anchor` (a tool, the toolbar), `gap` +/// from it: on its right when it fits in `screen`, else on its left (a toolbar docked right), +/// kept inside the screen either way. +pub(crate) fn beside(anchor: egui::Rect, width: f32, gap: f32, screen: egui::Rect) -> f32 { + let right = anchor.right() + gap; + let x = if right + width <= screen.right() { right } else { anchor.left() - gap - width }; + x.min(screen.right() - width).max(screen.left()) +} + fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) { if app.ui.flyout.is_none() { return; @@ -839,12 +909,13 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) { let active = app.session.tool_id(); let toolbar = ctx.data(|d| d.get_temp::>(flyout_toolbar_id())).flatten(); let mut input = FlyoutInput::default(); - let resp = egui::Area::new(egui::Id::unique("tool-flyout")).order(egui::Order::Foreground).fixed_pos(anchor.right_top() + vec2(8.0, -1.0)).show( - ctx, - |ui| { + // Beside the tool, on the side with room for it: toward the canvas from a toolbar docked right. + let size = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).map_or(vec2(160.0, 0.0), |r| r.size()); + let x = beside(anchor, size.x, 8.0, room(app, ctx)); + let resp = + egui::Area::new(egui::Id::unique("tool-flyout")).order(egui::Order::Foreground).fixed_pos(pos2(x, anchor.top() - 1.0)).show(ctx, |ui| { flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false, toolbar.as_deref())); - }, - ); + }); // Dragging the bar tears the flyout off, and so does releasing a press over it (a click, or the // end of the long press that opened the flyout): it floats where the pointer is. let at = ctx.input(|i| i.pointer.interact_pos()); diff --git a/crates/ui-egui/src/tools_float.rs b/crates/ui-egui/src/tools_float.rs index 25d86b69f..0cc643342 100644 --- a/crates/ui-egui/src/tools_float.rs +++ b/crates/ui-egui/src/tools_float.rs @@ -1,11 +1,14 @@ //! The Tools panel floats by its title bar and docks again on the window's left edge //! ([`crate::toolbar`]). It stays outside the docking layout: dragging its title bar out of the //! docked panel floats it under the pointer, a floating panel's title bar moves it, and a release -//! over the strip along the window's left edge (lit up) docks it there. Escape or losing the -//! window's focus puts it back where the drag began. +//! over the strip along the window's left edge (lit up) docks it there. With Keep Tools Beside the +//! Canvas on (`UiState::tools_beside_canvas`), a release over the canvas's left edge docks it too, +//! and either release docks it beside the canvas. A release over the canvas's right edge (left of +//! the panels) or the window's right edge docks it on the right, between the canvas and the panels. +//! Escape or losing the window's focus puts it back where the drag began. //! //! `window.panel.float {panel: "tools"}` / `window.panel.dock {panel: "tools"}` do the same for -//! agents. Its position is kept with the preferences and in user workspaces, and a position saved +//! agents (`side: right` docks it on the right). Its position and side are kept with the preferences and in user workspaces, and a position saved //! on a bigger window is clamped into this one when drawn. use egui::{CornerRadius, Id, Key, Pos2, Rect, Stroke, Vec2, pos2, vec2}; @@ -27,6 +30,8 @@ struct Move { grab: Vec2, from: Pos2, original: Option<[f32; 2]>, + /// The side it was docked on before the drag. + right: bool, } fn move_id() -> Id { @@ -41,11 +46,47 @@ pub(crate) fn escape_cancelled_move(ctx: &egui::Context) -> bool { ctx.data(|d| d.get_temp::(escape_cancel_id())) == Some(ctx.cumulative_frame_nr_for(ctx.viewport_id())) } -/// The strip along the window's left edge where the Tools panel docks, as last laid out. +/// The strip along the canvas's left edge where the Tools panel docks while Keep Tools Beside the +/// Canvas is on, as last laid out. pub(crate) fn tools_zone_id() -> Id { Id::unique("tools-float-zone") } +/// The strip along the canvas's right edge, left of the panels, where the Tools panel docks on the +/// right, as last laid out. +pub(crate) fn tools_zone_right_id() -> Id { + Id::unique("tools-float-zone-right") +} + +/// Accept a drop left of the panels docked right: the canvas's right edge. +pub(crate) fn set_right_dock_zone(ctx: &egui::Context, canvas: Rect, width: f32) { + let zone = Rect::from_min_max(pos2(canvas.right() - width, canvas.top()), canvas.max); + ctx.data_mut(|d| d.insert_temp(tools_zone_right_id(), zone)); +} + +fn window_zone_id() -> Id { + Id::unique("tools-float-window-zone") +} + +/// Accept a drop next to panels docked left, independently of the window-edge gesture. +pub(crate) fn set_dock_zones(ctx: &egui::Context, canvas: Rect, width: f32) { + ctx.data_mut(|d| { + d.insert_temp(tools_zone_id(), Rect::from_min_size(canvas.min, vec2(width, canvas.height()))); + }); +} + +/// Forget the canvas-edge target, so a strip laid out while Keep Tools Beside the Canvas was on +/// does not dock Tools after it is turned off. +pub(crate) fn clear_canvas_dock_zone(ctx: &egui::Context) { + ctx.data_mut(|d| d.remove::(tools_zone_id())); +} + +/// The legacy 48-point window-edge target spans the full layout below the bars, including +/// panels above and below the canvas. Its width does not follow the toolbar's column count. +pub(crate) fn set_window_dock_zone(ctx: &egui::Context, window: Rect) { + ctx.data_mut(|d| d.insert_temp(window_zone_id(), Rect::from_min_size(window.min, vec2(48.0, window.height())))); +} + /// `pos` moved so a panel of `size` stays inside `screen` (a position saved on a bigger window). pub fn clamp(pos: [f32; 2], size: Vec2, screen: Rect) -> Pos2 { let [x, y] = pos; @@ -67,7 +108,7 @@ pub(crate) fn cancel_move_on_escape(app: &mut VectorcraftApp, ctx: &egui::Contex fn start(app: &VectorcraftApp, ctx: &egui::Context, grab: Vec2) { let Some(from) = ctx.input(|i| i.pointer.press_origin().or(i.pointer.interact_pos())) else { return }; - ctx.data_mut(|d| d.insert_temp(move_id(), Move { grab, from, original: app.ui.toolbar_pos })); + ctx.data_mut(|d| d.insert_temp(move_id(), Move { grab, from, original: app.ui.toolbar_pos, right: app.ui.toolbar_right })); } /// The Tools panel's title bar was pressed (`title`, the panel's bounds `bounds`): docked, dragging @@ -90,9 +131,23 @@ pub(crate) fn drag_tools(app: &mut VectorcraftApp, ctx: &egui::Context, title: & } } -/// The docking strip under `at`, if any. -fn drop_zone(ctx: &egui::Context, at: Pos2) -> Option { - ctx.data(|d| d.get_temp::(tools_zone_id())).filter(|r| r.contains(at)) +/// The docking strip under `at`, if any, and whether it docks the panel on the right: the window's +/// left edge, with `beside_canvas` the canvas's left edge as well, and on the right the canvas's +/// right edge or anywhere along the window's right edge (which lights up the strip left of the +/// panels, where it docks). +fn drop_zone(ctx: &egui::Context, at: Pos2, beside_canvas: bool) -> Option<(Rect, bool)> { + let (left, right) = ctx.data(|d| { + let canvas = d.get_temp::(tools_zone_id()).filter(|_| beside_canvas); + let left = [canvas, d.get_temp::(window_zone_id())].into_iter().flatten().find(|r| r.contains(at)); + (left, d.get_temp::(tools_zone_right_id())) + }); + if let Some(r) = left { + return Some((r, false)); + } + let r = right?; + // Read outside the data lock above: egui's context locks are not re-entrant. + let at_edge = at.x >= ctx.content_rect().right() - crate::toolbar::WIDTH && r.y_range().contains(at.y); + (r.contains(at) || at_edge).then_some((r, true)) } fn highlight(ctx: &egui::Context, zone: Rect) { @@ -108,6 +163,7 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { let Some(m) = ctx.data(|d| d.get_temp::(move_id())) else { return }; if !gesture_allowed(ctx) { app.ui.toolbar_pos = m.original; + app.ui.toolbar_right = m.right; ctx.data_mut(|d| d.remove::(move_id())); if ctx.input(|i| i.key_pressed(Key::Escape)) { let frame = ctx.cumulative_frame_nr_for(ctx.viewport_id()); @@ -116,7 +172,7 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { return; } let at = ctx.input(|i| i.pointer.interact_pos()); - let zone = at.filter(|at| at.distance(m.from) > TRAVEL).and_then(|at| drop_zone(ctx, at)); + let zone = at.filter(|at| at.distance(m.from) > TRAVEL).and_then(|at| drop_zone(ctx, at, app.ui.tools_beside_canvas)); if ctx.input(|i| i.pointer.primary_down()) { if let Some(p) = at.map(|at| at - m.grab) && p.x.is_finite() @@ -125,14 +181,15 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { { app.ui.toolbar_pos = Some([p.x, p.y]); } - if let Some(zone) = zone { + if let Some((zone, _)) = zone { highlight(ctx, zone); } return; } ctx.data_mut(|d| d.remove::(move_id())); - if zone.is_some() { + if let Some((_, right)) = zone { app.ui.toolbar_pos = None; + app.ui.toolbar_right = right; } } @@ -161,9 +218,24 @@ pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Ok(app.ui.toolbar_right), + // `Start` and `End`, as panels take them, too. + Some(Value::String(s)) if s.eq_ignore_ascii_case("left") || s.eq_ignore_ascii_case("start") => Ok(false), + Some(Value::String(s)) if s.eq_ignore_ascii_case("right") || s.eq_ignore_ascii_case("end") => Ok(true), + Some(Value::String(s)) => Err(format!("side is left or right, not `{s}`")), + // A number, a list…: an error, not the last side. + Some(v) => Err(format!("side is left or right, not `{v}`")), + }; + Some(position(p).and_then(|at| { + let right = right?; app.ui.toolbar_pos = float.then(|| at.map_or(TOOLS_AT, |p| [p.x, p.y])); app.ui.toolbar |= float; - json!({ "panel": "tools", "floating": float, "pos": app.ui.toolbar_pos }) + if !float { + app.ui.toolbar_right = right; + } + let side = if app.ui.toolbar_right { "right" } else { "left" }; + Ok(json!({ "panel": "tools", "floating": float, "pos": app.ui.toolbar_pos, "side": side })) })) } diff --git a/crates/ui-egui/src/unsaved.rs b/crates/ui-egui/src/unsaved.rs index aa5c107e4..7b068bc7e 100644 --- a/crates/ui-egui/src/unsaved.rs +++ b/crates/ui-egui/src/unsaved.rs @@ -23,11 +23,19 @@ pub(crate) fn refuse_while_picking(app: &VectorcraftApp) -> Result<(), String> { if app.file_sheet_open() { Err(crate::picks::busy().into()) } else { Ok(()) } } +/// Finishes the work that decides whether there is anything left to save: the saves running in +/// the background, and an edit the active tool is computing on another thread (a Shape Builder +/// merge), which is applied. +pub fn finish_pending(app: &mut VectorcraftApp) { + crate::background::wait_all(app); + let r = app.session.finish_tool_edit(); + crate::canvas::apply_requests(app, r); +} + /// File → Close (or a tab's ×) for document `i`. pub fn close(app: &mut VectorcraftApp, i: usize) -> Result { refuse_while_picking(app)?; - // A save still running decides whether there is anything left to save. - crate::background::wait_all(app); + finish_pending(app); let dirty = app.session.documents().get(i).ok_or("no such document")?.is_dirty(); if dirty { ask(app, i, "close") } else { close_now(app, i) } } @@ -36,7 +44,7 @@ pub fn close(app: &mut VectorcraftApp, i: usize) -> Result { /// or finish once none is left. pub fn close_all(app: &mut VectorcraftApp, then: &str) -> Result { refuse_while_picking(app)?; - crate::background::wait_all(app); + finish_pending(app); if let Some(i) = app.session.documents().iter().position(|d| d.is_dirty()) { return ask(app, i, then); } diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index a62a221c5..77a00c33a 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -5,7 +5,7 @@ use serde_json::Value; use crate::VectorcraftApp; #[cfg(not(target_arch = "wasm32"))] -use craft_update::updater::{Action, Updater}; +use craft_update::updater::{Action, Status, Updater}; #[cfg(not(target_arch = "wasm32"))] use craft_update::{CheckResultDialog, CheckResultResponse}; @@ -20,10 +20,13 @@ pub(crate) struct Runtime { /// The context of the last frame, to wake the UI when a check is queued from a menu callback. #[cfg(not(target_arch = "wasm32"))] repaint: Option, - /// Whether the result of Help ▸ Check for Updates is in view. - pub(crate) manual_window_open: bool, + /// Whether an update dialog held the input in the last frame. + #[cfg(not(target_arch = "wasm32"))] + was_blocking: bool, /// The web build has no updater; its window only says so. #[cfg(target_arch = "wasm32")] + pub(crate) manual_window_open: bool, + #[cfg(target_arch = "wasm32")] pub(crate) status_message: Option, } @@ -38,6 +41,9 @@ impl Default for Runtime { check_requested: false, #[cfg(not(target_arch = "wasm32"))] repaint: None, + #[cfg(not(target_arch = "wasm32"))] + was_blocking: false, + #[cfg(target_arch = "wasm32")] manual_window_open: false, #[cfg(target_arch = "wasm32")] status_message: None, @@ -51,13 +57,20 @@ impl VectorcraftApp { pub fn set_startup_update_check(&mut self, on: bool) { self.updates_rt.startup_check = on; } + + /// Starts the install helper of an update that is ready, so it installs once the process has + /// exited (does nothing when no update is ready). Call it in `on_exit` when the process ends + /// there, since the app is then never dropped. + #[cfg(not(target_arch = "wasm32"))] + pub fn start_pending_update(&mut self) { + self.updates_rt.updater.start_pending_update(); + } } pub(crate) fn check(app: &mut VectorcraftApp) -> Result { #[cfg(not(target_arch = "wasm32"))] { app.updates_rt.check_requested = true; - app.updates_rt.manual_window_open = true; if let Some(ctx) = &app.updates_rt.repaint { ctx.request_repaint(); } @@ -87,32 +100,81 @@ pub(crate) fn tick(app: &mut VectorcraftApp, ctx: &egui::Context) { let _ = (app, ctx); } +/// Whether the check result window shows: for every status but idle (a requested check, and the +/// first start after an update, which reports that it installed or why it failed), and never +/// over the startup prompt. +#[cfg(not(target_arch = "wasm32"))] +fn result_window_shows(status: &Status, prompt_open: bool) -> bool { + !prompt_open && !matches!(status, Status::Idle) +} + +/// Whether the update prompt or the modal result window is open: keys, tool keys and clipboard +/// events then stay out of the documents behind it, and the app's other windows take no input. +pub(crate) fn blocks_input(app: &VectorcraftApp) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + let updater = &app.updates_rt.updater; + holds_keyboard(updater.status(), updater.prompt_open()) + } + #[cfg(target_arch = "wasm32")] + { + let _ = app; + false + } +} + +/// The prompt is modal, and so is the result window for every status but [`Status::Updated`], a +/// notice that leaves the app usable behind it. +#[cfg(not(target_arch = "wasm32"))] +fn holds_keyboard(status: &Status, prompt_open: bool) -> bool { + prompt_open || !matches!(status, Status::Idle | Status::Updated(_)) +} + +/// Whether an update may install and close the app: no document holds unsaved work, no save, +/// export, file opening, plug-in filter or Image Trace is running, no action is playing, no dialog +/// or file dialog is open, and no tool is in the middle of a drag or an edit it computes on +/// another thread. +#[cfg(not(target_arch = "wasm32"))] +fn safe_to_close(app: &VectorcraftApp) -> bool { + let session = &app.session; + !crate::unsaved::any_dirty(app) + && app.background.jobs.is_empty() + && !crate::opening::long_operation_busy(app) + && app.ui.action_playing.is_none() + && app.ui.dialog.is_none() + && !app.file_dialog_open() + && !session.in_interaction() + && !session.tool_busy() + && !session.tool_editing() +} + pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { #[cfg(not(target_arch = "wasm32"))] { - // The restart helper is scheduled before the app closes, so no update starts over unsaved work. - let clean = !crate::unsaved::any_dirty(app); - match app.updates_rt.updater.show_if(ctx, clean) { + // An installation, and the close for a ready update, wait for unsaved and running work. + let safe = safe_to_close(app); + match app.updates_rt.updater.show_if(ctx, safe) { Action::OpenUrl(url) => app.open_url(&url), Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close), Action::None => {} } // An available update goes to the prompt above, which replaces the result dialog. let rt = &mut app.updates_rt; - if rt.manual_window_open && rt.updater.prompt_open() { - rt.manual_window_open = false; - } - if rt.manual_window_open { + if result_window_shows(rt.updater.status(), rt.updater.prompt_open()) { match CheckResultDialog::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).show(ctx, rt.updater.status()) { CheckResultResponse::None => {} - CheckResultResponse::Close => rt.manual_window_open = false, + // Closing the dialog dismisses the outcome it showed. + CheckResultResponse::Close => rt.updater.dismiss_status(), CheckResultResponse::CheckAgain => rt.updater.check_now(ctx), + CheckResultResponse::RestartToUpdate => rt.updater.restart_to_update(), } } - // Closing the dialog dismisses the outcome it showed. - if !rt.manual_window_open { - rt.updater.dismiss_status(); + // A check at startup can open the prompt while a floating document's window covers it. + let blocking = blocks_input(app); + if blocking && !app.updates_rt.was_blocking { + crate::doc_windows::focus_main_window(app, ctx); } + app.updates_rt.was_blocking = blocking; } #[cfg(target_arch = "wasm32")] @@ -139,16 +201,114 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } } +/// Opens the update prompt for a test in another module. +#[cfg(all(test, not(target_arch = "wasm32")))] +pub(crate) fn open_prompt_for_test(app: &mut VectorcraftApp) { + tests::offer(app); +} + +/// Opens the result window of a running check for a test in another module. +#[cfg(all(test, not(target_arch = "wasm32")))] +pub(crate) fn open_result_window_for_test(app: &mut VectorcraftApp, ctx: &egui::Context) { + app.updates_rt.updater = tests::isolated_updater(env!("CARGO_PKG_VERSION"), "result"); + app.updates_rt.updater.check_now(ctx); +} + +/// Closes the update prompt and the result window for a test in another module. +#[cfg(all(test, not(target_arch = "wasm32")))] +pub(crate) fn close_dialogs_for_test(app: &mut VectorcraftApp) { + app.updates_rt.updater = tests::isolated_updater(env!("CARGO_PKG_VERSION"), "closed"); +} + #[cfg(test)] mod tests { use super::*; - #[cfg(not(target_arch = "wasm32"))] - use craft_update::updater::Status; + use serde_json::json; use vectorcraft_engine::Session; + use vectorcraft_tools::{PointerEvent, PointerKind}; + + /// The folder that holds `name`'s update records. + #[cfg(not(target_arch = "wasm32"))] + fn update_root(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("vectorcraft-updates-{}-{name}", std::process::id())) + } + + /// An updater that reads its records and the launcher's pins only under [`update_root`]. + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn isolated_updater(version: &str, name: &str) -> Updater { + let root = update_root(name); + Updater::new(&crate::UPDATE_APP, version) + .with_source(std::sync::Arc::new(|| Ok(None))) + .with_paths(craft_update::Paths::under(&root)) + .with_pins_path(root.join("pins.json")) + } + + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn offer(app: &mut VectorcraftApp) { + app.updates_rt.updater = isolated_updater(env!("CARGO_PKG_VERSION"), "prompt"); + app.updates_rt.updater.offer(craft_update::Release { + tag: "v99.0.0".into(), + version: craft_update::parse_version("99.0.0"), + name: "VectorCraft 99.0.0".into(), + html_url: "https://github.com/storytold/vectorcraft/releases/tag/v99.0.0".into(), + ..Default::default() + }); + } + + /// An app with one saved document holding a selected rectangle. + #[cfg(not(target_arch = "wasm32"))] + fn app_with_saved_object() -> VectorcraftApp { + let services = crate::Services { write: Some(Box::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app.run("file.save", json!({"path": "/docs/saved.vectorcraft"})).unwrap(); + assert!(!crate::unsaved::any_dirty(&app)); + app + } + + #[cfg(not(target_arch = "wasm32"))] + fn press(key: egui::Key) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE } + } + + /// One frame in the app's order: the shortcuts read the input, then the update dialogs draw. + #[cfg(not(target_arch = "wasm32"))] + fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) { + let mut output = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + crate::shortcuts::handle(app, ui.ctx()); + show(app, ui.ctx()); + }); + output.textures_delta.clear(); + } + + /// A Copy, the Pen tool's key and Delete, in one frame each: none of them may act. + #[cfg(not(target_arch = "wasm32"))] + fn assert_keys_do_nothing(app: &mut VectorcraftApp, ctx: &egui::Context) { + let tool = app.session.tool_id(); + let mut reached = Vec::new(); + frame(app, ctx, vec![egui::Event::Copy]); + if !app.session.clipboard.is_empty() { + reached.push("Copy"); + } + let tool_key = + egui::Event::Key { key: egui::Key::P, physical_key: Some(egui::Key::P), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + frame(app, ctx, vec![tool_key, egui::Event::Text("p".into())]); + if app.session.tool_id() != tool { + reached.push("the tool key"); + } + frame(app, ctx, vec![press(egui::Key::Delete)]); + if app.session.doc().unwrap().selection.is_empty() { + reached.push("Delete"); + } + assert_eq!(reached, Vec::<&str>::new(), "these reached the document"); + assert!(!crate::unsaved::any_dirty(app), "a key edited the document"); + } #[test] fn default_runtime_is_idle() { let rt = Runtime::default(); + #[cfg(target_arch = "wasm32")] assert!(!rt.manual_window_open); #[cfg(not(target_arch = "wasm32"))] { @@ -158,6 +318,26 @@ mod tests { } } + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn result_window_shows_every_status_but_idle() { + let shown = [ + Status::Checking, + Status::UpToDate, + Status::Failed("offline".into()), + Status::Installing, + Status::Ready, + Status::Managed, + Status::Updated("99.0.0".into()), + Status::InstallFailed("locked".into()), + ]; + for status in &shown { + assert!(result_window_shows(status, false), "{status:?} shows"); + assert!(!result_window_shows(status, true), "{status:?} waits for the prompt"); + } + assert!(!result_window_shows(&Status::Idle, false)); + } + #[test] #[cfg(not(target_arch = "wasm32"))] fn startup_prompt_shows_and_closes() { @@ -178,4 +358,164 @@ mod tests { output.textures_delta.clear(); assert!(app.updates_rt.updater.prompt_open()); } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn the_prompt_and_the_result_window_hold_the_keyboard_but_the_updated_notice_does_not() { + for status in [ + Status::Checking, + Status::UpToDate, + Status::Failed("offline".into()), + Status::Installing, + Status::Ready, + Status::Managed, + Status::InstallFailed("locked".into()), + ] { + assert!(holds_keyboard(&status, false), "{status:?}"); + } + assert!(!holds_keyboard(&Status::Idle, false)); + assert!(!holds_keyboard(&Status::Updated("99.0.0".into()), false)); + assert!(holds_keyboard(&Status::Idle, true), "the prompt"); + } + + /// The update prompt, drawn: keys stop at it, Escape closes it, and the next key acts again. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn keys_do_not_reach_the_document_behind_the_update_prompt() { + let mut app = app_with_saved_object(); + let ctx = egui::Context::default(); + offer(&mut app); + frame(&mut app, &ctx, vec![]); + assert_keys_do_nothing(&mut app, &ctx); + assert!(app.updates_rt.updater.prompt_open()); + + frame(&mut app, &ctx, vec![press(egui::Key::Escape)]); + assert!(!app.updates_rt.updater.prompt_open(), "Escape closes the prompt"); + assert!(!app.session.doc().unwrap().selection.is_empty(), "Escape stops at the prompt"); + frame(&mut app, &ctx, vec![press(egui::Key::Delete)]); + assert!(crate::unsaved::any_dirty(&app), "Delete acts once the prompt has closed"); + } + + /// The result window of a check that is still running, drawn: keys stop at it too. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn keys_do_not_reach_the_document_behind_the_check_result_window() { + let mut app = app_with_saved_object(); + let ctx = egui::Context::default(); + app.updates_rt.updater = isolated_updater("0.0.1", "checking"); + app.updates_rt.updater.check_now(&ctx); + assert_eq!(app.updates_rt.updater.status(), &Status::Checking); + frame(&mut app, &ctx, vec![]); + assert_keys_do_nothing(&mut app, &ctx); + } + + /// The notice of a finished update, drawn: the keys act behind it. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn keys_reach_the_document_behind_the_updated_notice() { + let mut app = app_with_saved_object(); + let ctx = egui::Context::default(); + let root = update_root("updated"); + let records = craft_update::Paths::under(&root).data; + std::fs::create_dir_all(&records).unwrap(); + std::fs::write(records.join("self-update-done.txt"), "v99.0.0").unwrap(); + app.updates_rt.updater = isolated_updater("99.0.0", "updated"); + app.updates_rt.updater.check_at_start(&ctx); + assert_eq!(app.updates_rt.updater.status(), &Status::Updated("99.0.0".into())); + frame(&mut app, &ctx, vec![]); + frame(&mut app, &ctx, vec![press(egui::Key::Delete)]); + let _ = std::fs::remove_dir_all(&root); + assert!(crate::unsaved::any_dirty(&app), "Delete acts behind the notice"); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn unsaved_changes_dialogs_and_running_work_hold_an_update_back() { + let mut app = app_with_saved_object(); + assert!(safe_to_close(&app)); + + app.run("shape.rectangle", json!({"x": 70, "y": 10, "width": 50, "height": 50})).unwrap(); + assert!(!safe_to_close(&app), "unsaved changes"); + app.run("file.save", json!({"path": "/docs/saved.vectorcraft"})).unwrap(); + assert!(safe_to_close(&app)); + + app.ui.dialog = Some(crate::state::Dialog::new("about", json!({}))); + assert!(!safe_to_close(&app), "a dialog"); + app.ui.dialog = None; + + let (shown, _) = crate::tests_picks::off_the_ui_thread(&mut app.services); + // The command asks again with the answer: until then it reports that it has none. + assert!(app.run("file.open", json!({})).is_err()); + assert!(app.file_dialog_open()); + assert!(!safe_to_close(&app), "a file dialog"); + shown.answer(&[]); + crate::picks::poll(&mut app, &egui::Context::default()); + assert!(!app.file_dialog_open()); + assert!(safe_to_close(&app)); + + app.services.write_shared = Some(std::sync::Arc::new(|_: &str, _: &[u8]| Ok(()))); + let (release, held) = std::sync::mpsc::channel::<()>(); + let work = move |_: crate::background::Writer| { + let _ = held.recv(); + Ok(Value::Null) + }; + // No label: a job the status bar does not list holds the close back too. + crate::background::run(&mut app, true, String::new(), None, work, |_, r| r).unwrap(); + assert!(!safe_to_close(&app), "a background save"); + let _ = release.send(()); + crate::background::wait_all(&mut app); + assert!(safe_to_close(&app)); + + crate::opening::start(&mut app, "/art/Wait.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(10.0, 10.0), false)) + .unwrap(); + assert!(!safe_to_close(&app), "a file opening"); + app.opening.pending.clear(); + assert!(safe_to_close(&app)); + + use crate::panels::actions::{Action, ActionSet, PlaybackSpeed, play}; + let step = ("object.move".to_owned(), json!({"dx": 1, "dy": 0})); + app.ui.action_sets = vec![ActionSet::new("Test", vec![Action::new("Move", vec![step.clone(), step])])]; + app.ui.action_playback.speed = PlaybackSpeed::Step; + assert_eq!(play(&mut app, 0, 0).unwrap()["pending"], json!(1)); + app.run("file.save", json!({"path": "/docs/saved.vectorcraft"})).unwrap(); + crate::background::wait_all(&mut app); + assert!(!safe_to_close(&app), "an action playing"); + app.ui.action_playing = None; + assert!(safe_to_close(&app)); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn a_gesture_or_a_tools_edit_holds_an_update_back() { + let mut app = app_with_saved_object(); + let view = app.view_info(); + app.session.begin_interaction("Move").unwrap(); + assert!(!safe_to_close(&app), "an interaction"); + app.session.cancel_interaction().unwrap(); + assert!(safe_to_close(&app)); + + app.select_tool("selection"); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 150.0, 150.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 180.0, 180.0), view).unwrap(); + assert!(app.session.tool_busy() && !app.session.in_interaction()); + assert!(!safe_to_close(&app), "a drag with a tool"); + app.session.pointer(&PointerEvent::new(PointerKind::Up, 180.0, 180.0), view).unwrap(); + assert!(!app.session.tool_busy()); + assert!(safe_to_close(&app)); + + // Enough segments for the merge to run on another thread. + for k in 0..40 { + app.run("shape.rectangle", json!({"x": 10 * k, "y": 100, "width": 20, "height": 20})).unwrap(); + } + app.run("file.save", json!({"path": "/docs/saved.vectorcraft"})).unwrap(); + app.run("select.all", json!({})).unwrap(); + app.select_tool("shapeBuilder"); + app.session.pointer(&PointerEvent::new(PointerKind::Move, 15.0, 110.0), view).unwrap(); + app.session.tool_poll(view, true).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 15.0, 110.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Up, 15.0, 110.0), view).unwrap(); + assert!(app.session.tool_editing() && !crate::unsaved::any_dirty(&app), "the click starts a merge on another thread"); + assert!(!safe_to_close(&app), "a tool's edit"); + crate::unsaved::finish_pending(&mut app); + } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e4319809b..4f3243c75 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -33,12 +33,12 @@ pub(crate) use buttons::{button_width, icon_button_info, primary_button_width, s #[cfg(test)] use chips::gradient_chip_colors; pub use chips::{ProxyClicks, chip_button, chip_button_named, fill_stroke_proxy, gradient_chip, paint_chip, proxy_chip, swatch_tile}; -pub(crate) use color::{HEX_FIELD_CHARS, hex_text, pointer_phase}; pub use color::{ - Live, SliderValue, WheelResponse, channel_slider, channel_slider_named, channel_slider_valued, color_field, color_mesh, color_slider, - color_slider_named, color_slider_stepped, color_slider_valued, harmony_wheel, hex_field, hex_field_named, region_texture, segment_color, - segmented_wheel, stepped_area, + FieldAxis, Live, RingResponse, SliderValue, WheelResponse, channel_slider, channel_slider_named, channel_slider_valued, color_field, color_mesh, + color_slider, color_slider_named, color_slider_stepped, color_slider_valued, harmony_wheel, hex_field, hex_field_named, hsb32, hue_ring, + region_texture, ring_square, segment_color, segmented_wheel, stepped_area, }; +pub(crate) use color::{HEX_FIELD_CHARS, hex_text, pointer_phase}; pub use drag::{ PanelDrag, RowClick, SwatchRows, apply_row_click, art_drop, doc_preview, drag_ghost, drag_source, drop_target_outline, ghost_row, row_click, }; @@ -711,6 +711,25 @@ mod gradient_chip_tests { assert_eq!(colors[0], Color32::BLACK); } + #[test] + fn freeform_chip_mixes_points_in_the_model_they_share() { + use vectorcraft_color::{Color, Freeform, FreeformPoint}; + use vectorcraft_geom::kurbo::Point; + let chip = |a: Color, b: Color| { + let p = |x, c| FreeformPoint::new(Point::new(x, 10.0), c); + let mut gp = paint(GradientKind::Freeform, 0.0); + gp.freeform = Some(Freeform { points: vec![p(0.0, a), p(20.0, b)], ..Default::default() }); + gradient_chip_colors(&gp, 20.0, 20.0, 4, 4)[2 * 5 + 2] + }; + let near = |got: Color32, want: [u8; 4]| [got.r(), got.g(), got.b()].iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 3); + let got = chip(Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0)); + let want = Color::cmyk(0.5, 0.0, 0.0, 0.5).to_rgba8(1.0); + assert!(near(got, want), "CMYK: {got:?} vs {want:?}"); + let got = chip(Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0)); + let want = Color::lab(60.0, 0.0, 0.0).to_rgba8(1.0); + assert!(near(got, want), "Lab: {got:?} vs {want:?}"); + } + /// At a position and display scale that fall between pixels, the gradient chip covers the same /// pixel rows as a colour or None chip beside it (#3826). Filled rectangles are drawn on whole /// pixels, so the toolbar's Gradient button sat up to half a pixel off its neighbors. diff --git a/crates/ui-egui/src/widgets/chips.rs b/crates/ui-egui/src/widgets/chips.rs index 520b8d243..a6b83f700 100644 --- a/crates/ui-egui/src/widgets/chips.rs +++ b/crates/ui-egui/src/widgets/chips.rs @@ -71,10 +71,10 @@ pub(super) fn gradient_chip_colors(gp: &vectorcraft_color::GradientPaint, width: let n = (cols + 1) * (rows + 1); if gp.gradient.kind == GradientKind::Freeform { let Some(painted) = vectorcraft_color::freeform::painted_box(b) else { return vec![Color32::TRANSPARENT; n] }; - let field = gp.freeform_on(painted).field(vectorcraft_color::freeform::spread_scale(painted)); + let field = gp.freeform_on(painted).model_field(vectorcraft_color::freeform::spread_scale(painted)); return (0..n) .map(|k| { - let ([r, g, b], _) = field.sample(at(k)); + let ([r, g, b], _) = field.display_sample(at(k)); let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; Color32::from_rgb(q(r), q(g), q(b)) }) diff --git a/crates/ui-egui/src/widgets/color.rs b/crates/ui-egui/src/widgets/color.rs index 72f74f7b9..f3d34d23e 100644 --- a/crates/ui-egui/src/widgets/color.rs +++ b/crates/ui-egui/src/widgets/color.rs @@ -1,5 +1,5 @@ use super::labels::{label_field, name_field, take_previous_label}; -use super::numeric::{STEP_DECIMALS, field_text, round_to, step_to_multiple}; +use super::numeric::{STEP_DECIMALS, STEPS, field_text, round_to, step_to_multiple}; use super::text::{after_shift_enter, select_all, select_all_on_focus, shift_enter_keeps_focus}; use super::{Color, Color32, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, id_with, pos2, vec2}; @@ -320,31 +320,130 @@ fn gradient_strip(ui: &Ui, rect: Rect, n: usize, vertical: bool, track: &dyn Fn( } } -/// The Color Picker's 2D colour field: `color_at(x, y)` gives the colour at a point (0..1 each, y -/// down) and a ring marks `pos`. Returns the clicked or dragged point and the phase. +/// One axis of a [`color_field`] as keys and screen readers see it: the component's name, how many +/// of its units the axis spans (an arrow press moves one unit) and the units' suffix. +#[derive(Clone, Copy, Debug)] +pub struct FieldAxis<'a> { + pub name: &'a str, + pub units: f32, + pub suffix: &'a str, +} + +/// A press on `resp`, or a click whose press and release came in one frame, gives it the keyboard +/// focus, so the arrow keys reach it. Escape or a click elsewhere takes the focus away again. +fn focus_on_press(resp: &Response) { + if resp.is_pointer_button_down_on() || resp.clicked() { + resp.request_focus(); + } +} + +/// The arrow keys pressed for control `id` this frame while it has the keyboard focus, in units +/// ([`STEPS`]: ten with Shift, a tenth with Ctrl/Cmd, else one): (→ minus ←, ↑ minus ↓). The control +/// takes the arrows of each axis it uses (`horizontal`, `vertical`), which then do not move the focus +/// to the next widget. +fn arrow_steps(ui: &Ui, id: egui::Id, horizontal: bool, vertical: bool) -> (f32, f32) { + if !ui.memory(|m| m.has_focus(id)) { + return (0.0, 0.0); + } + use egui::Key; + let filter = egui::EventFilter { horizontal_arrows: horizontal, vertical_arrows: vertical, ..Default::default() }; + ui.memory_mut(|m| m.set_focus_lock_filter(id, filter)); + let (mut x, mut y) = (0.0, 0.0); + ui.input_mut(|i| { + for (mods, size) in STEPS { + let mut count = |key| i.count_and_consume_key(mods, key) as f64 * size; + if horizontal { + x += count(Key::ArrowRight) - count(Key::ArrowLeft); + } + if vertical { + y += count(Key::ArrowUp) - count(Key::ArrowDown); + } + } + }); + (x as f32, y as f32) +} + +/// The focus outline of a focused color control. +fn focus_outline(ui: &Ui, resp: &Response, rect: Rect) { + if ui.memory(|m| m.has_focus(resp.id)) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(rect.expand(2.0), 0.0, Stroke::new(1.0, t.accent), StrokeKind::Outside); + } +} + +/// A 2D color field (the Color Picker's, the Color Wheel panel's square): `color_at(x, y)` gives +/// the color at a point (0..1 each, y down) and, when `marked`, a ring marks `pos`. A press gives +/// it the keyboard focus. While it has the focus, → and ← move `pos` across by one unit of +/// `axes[0]` and ↑ and ↓ move it up and down by one unit of `axes[1]`, marked or not. Screen +/// readers read `name` and, when `marked`, both components. They read it as a slider over +/// `axes[0]`'s units (0 when not `marked`), which their increment and decrement actions move as → +/// and ← do and their set-value action sets, as for a [`hue_ring`]. Returns the clicked or dragged +/// point, or the point the keys or actions moved to, and the phase. When `marked`, a step past an +/// edge returns no point. pub fn color_field( ui: &mut Ui, id: impl std::hash::Hash, + (name, axes): (&str, [FieldAxis<'_>; 2]), size: Vec2, pos: (f32, f32), + marked: bool, color_at: &dyn Fn(f32, f32) -> Color32, ) -> (Option<(f32, f32)>, Live) { + use egui::accesskit::{Action, ActionData}; let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag()); + focus_on_press(&resp); let painter = ui.painter_at(rect.expand(6.0)); painter.add(color_mesh(rect, (32, 32), color_at)); painter.rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); - let c = pos2(rect.left() + pos.0.clamp(0.0, 1.0) * rect.width(), rect.top() + pos.1.clamp(0.0, 1.0) * rect.height()); - painter.circle_stroke(c, 5.0, Stroke::new(1.0, Color32::BLACK)); - painter.circle_stroke(c, 4.0, Stroke::new(1.0, Color32::WHITE)); - match pointer_phase(&resp) { - Some((p, phase)) => { - let f = |v: f32, lo: f32, len: f32| ((v - lo) / len).clamp(0.0, 1.0); - (Some((f(p.x, rect.left(), rect.width()), f(p.y, rect.top(), rect.height()))), phase) - } - None => (None, Live::Idle), + focus_outline(ui, &resp, rect); + let (x, y) = pos; + if marked { + let c = pos2(rect.left() + x.clamp(0.0, 1.0) * rect.width(), rect.top() + y.clamp(0.0, 1.0) * rect.height()); + painter.circle_stroke(c, 5.0, Stroke::new(1.0, Color32::BLACK)); + painter.circle_stroke(c, 4.0, Stroke::new(1.0, Color32::WHITE)); } + let value = |v: f32, a: &FieldAxis| format!("{}{}", (v.clamp(0.0, 1.0) * a.units).round(), a.suffix); + let text = marked.then(|| { + crate::i18n::fmt( + tl!("{name1} {value1}, {name2} {value2}"), + &[("name1", axes[0].name), ("value1", &value(x, &axes[0])), ("name2", axes[1].name), ("value2", &value(1.0 - y, &axes[1]))], + ) + }); + let shown = if marked { f64::from(x.clamp(0.0, 1.0) * axes[0].units) } else { 0.0 }; + resp.widget_info(|| egui::WidgetInfo { current_text_value: text.clone(), ..egui::WidgetInfo::slider(ui.is_enabled(), shown, name) }); + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_min_numeric_value(0.0); + node.set_max_numeric_value(f64::from(axes[0].units)); + node.set_numeric_value_step(1.0); + for action in [Action::SetValue, Action::Increment, Action::Decrement] { + node.add_action(action); + } + }); + let (dx, dy) = arrow_steps(ui, resp.id, true, true); + let (steps, set) = ui.input(|i| { + let steps = + i.num_accesskit_action_requests(resp.id, Action::Increment) as f32 - i.num_accesskit_action_requests(resp.id, Action::Decrement) as f32; + let set = i.accesskit_action_requests(resp.id, Action::SetValue).find_map(|r| match r.data { + Some(ActionData::NumericValue(v)) if v.is_finite() => Some(v as f32), + _ => None, + }); + (steps, set) + }); + if let Some((p, phase)) = pointer_phase(&resp) { + let f = |v: f32, lo: f32, len: f32| ((v - lo) / len).clamp(0.0, 1.0); + return (Some((f(p.x, rect.left(), rect.width()), f(p.y, rect.top(), rect.height()))), phase); + } + let across = set.map_or(x + (dx + steps) / axes[0].units, |v| v / axes[0].units); + let moved = (across.clamp(0.0, 1.0), (y - dy / axes[1].units).clamp(0.0, 1.0)); + // A step past an edge leaves the point where it is. Without a marker there is no color yet, so + // a key or an action applies even when the point stays. + let asked = set.is_some() || dx + steps != 0.0 || dy != 0.0; + if moved != (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)) || (asked && !marked) { + return (Some(moved), Live::Released); + } + (None, Live::Idle) } /// A smoothly shaded colour area: a `cols` × `rows` grid over `rect` with `color_at(x, y)` (0..1 @@ -628,6 +727,152 @@ fn wheel_shape(c: Pos2, r: f32, v: f32) -> egui::Shape { egui::Shape::mesh(mesh) } +/// The width of a [`hue_ring`]'s band. +const RING_WIDTH: f32 = 14.0; +/// How far inside its rect a [`hue_ring`]'s band is drawn: a press up to this far outside the band +/// still picks a hue. +const RING_MARGIN: f32 = 4.0; + +/// The display sRGB of HSB `[h°, s, v]` as an egui color, with no color conversion. +pub fn hsb32(hsb: [f32; 3]) -> Color32 { + let [r, g, b] = vectorcraft_color::hsb_to_rgb(hsb).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + Color32::from_rgb(r, g, b) +} + +/// The square a [`hue_ring`] drawn in `rect` holds: the largest one inside the band's inner edge, +/// 2 points clear of it. +pub fn ring_square(rect: Rect) -> Rect { + let inner = rect.width().min(rect.height()) / 2.0 - RING_MARGIN - RING_WIDTH - 2.0; + Rect::from_center_size(rect.center(), Vec2::splat(inner.max(0.0) * std::f32::consts::SQRT_2)) +} + +/// The part of a [`hue_ring`] a press started on. +#[derive(Clone, Copy, Debug, PartialEq)] +enum RingPart { + Band, + Inner, + Corner, +} + +/// What a [`hue_ring`] did this frame. +pub struct RingResponse { + /// The rect the ring is drawn in. + pub rect: Rect, + /// A new hue (degrees, 0..360) from a press or drag on the band, an arrow key or a screen + /// reader, and its phase. + pub hue: Option<(f32, Live)>, + /// The pointer of a press or drag that started inside the band's inner edge, and its phase. + pub inner: Option<(Pos2, Live)>, +} + +/// A hue ring `size` across: a band of hues drawn [`RING_MARGIN`] inside its rect, the hue +/// increasing counterclockwise from 3 o'clock as on [`harmony_wheel`], with a marker at `hue` when +/// `marked`. A press on the band, or up to [`RING_MARGIN`] outside it, picks the hue under the +/// pointer (with Shift, in 45° steps) and gives the ring the keyboard focus. A press inside the +/// band's inner edge comes back as `inner`, and a press in the rect's corners does nothing. The part +/// a press starts on keeps the whole drag. While the ring has the focus, → and ↑ raise `hue` by +/// 1° and ← and ↓ lower it ([`STEPS`]), marked or not, and 360° wraps to 0°. Screen readers read it +/// as a slider named `name` from 0 to 360 (0 when not `marked`), which their increment, decrement +/// and set-value actions move. +pub fn hue_ring(ui: &mut Ui, id: impl std::hash::Hash, name: &str, size: f32, hue: f32, marked: bool) -> RingResponse { + use egui::accesskit::{Action, ActionData}; + const SEG: u32 = 72; + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag()); + let c = rect.center(); + let outer = size / 2.0 - RING_MARGIN; + let inner = outer - RING_WIDTH; + let mut mesh = egui::Mesh::default(); + for k in 0..=SEG { + let h = k as f32 * 360.0 / SEG as f32; + let col = hsb32([h, 1.0, 1.0]); + mesh.colored_vertex(wheel_pos(c, inner, h, 1.0), col); + mesh.colored_vertex(wheel_pos(c, outer, h, 1.0), col); + } + for k in 0..SEG { + let a = 2 * k; + mesh.add_triangle(a, a + 1, a + 2); + mesh.add_triangle(a + 1, a + 3, a + 2); + } + let painter = ui.painter_at(rect); + painter.add(egui::Shape::mesh(mesh)); + painter.circle_stroke(c, outer, Stroke::new(1.0, t.border)); + painter.circle_stroke(c, inner, Stroke::new(1.0, t.border)); + if ui.memory(|m| m.has_focus(resp.id)) { + painter.circle_stroke(c, outer + 2.0, Stroke::new(1.0, t.accent)); + } + if marked { + let m = wheel_pos(c, (inner + outer) / 2.0, hue, 1.0); + painter.circle_stroke(m, RING_WIDTH / 2.0 - 1.0, Stroke::new(1.5, Color32::WHITE)); + painter.circle_stroke(m, RING_WIDTH / 2.0, Stroke::new(1.0, Color32::BLACK)); + } + resp.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), if marked { f64::from(hue) } else { 0.0 }, name)); + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_min_numeric_value(0.0); + node.set_max_numeric_value(360.0); + node.set_numeric_value_step(1.0); + for action in [Action::SetValue, Action::Increment, Action::Decrement] { + node.add_action(action); + } + }); + let part_at = |p: Pos2| match p.distance(c) { + d if d < inner => RingPart::Inner, + d if d <= outer + RING_MARGIN => RingPart::Band, + _ => RingPart::Corner, + }; + let part_id = resp.id.with("part"); + if resp.is_pointer_button_down_on() + && ui.input(|i| i.pointer.primary_pressed()) + && let Some(p) = resp.interact_pointer_pos() + { + ui.data_mut(|d| d.insert_temp(part_id, part_at(p))); + } + let pointer = pointer_phase(&resp); + // A click takes the part under it; a drag keeps the part its press started on. + let part = match pointer { + Some((p, _)) if resp.clicked() => Some(part_at(p)), + Some((p, _)) => Some(ui.data(|d| d.get_temp::(part_id)).unwrap_or_else(|| part_at(p))), + None => ui.data(|d| d.get_temp::(part_id)), + }; + if resp.clicked() || resp.drag_stopped() { + ui.data_mut(|d| d.remove::(part_id)); + } + if part == Some(RingPart::Band) { + focus_on_press(&resp); + } + let (dx, dy) = arrow_steps(ui, resp.id, true, true); + let (steps, set) = ui.input(|i| { + let steps = + i.num_accesskit_action_requests(resp.id, Action::Increment) as f32 - i.num_accesskit_action_requests(resp.id, Action::Decrement) as f32; + let set = i.accesskit_action_requests(resp.id, Action::SetValue).find_map(|r| match r.data { + Some(ActionData::NumericValue(v)) if v.is_finite() => Some(v.rem_euclid(360.0) as f32), + _ => None, + }); + (steps + dx + dy, set) + }); + let mut out = RingResponse { rect, hue: None, inner: None }; + if let Some((p, phase)) = pointer { + match part.unwrap_or_else(|| part_at(p)) { + RingPart::Band => { + let v = p - c; + let h = (-v.y).atan2(v.x).to_degrees(); + let h = if ui.input(|i| i.modifiers.shift) { (h / 45.0).round() * 45.0 } else { h }; + out.hue = Some((h.rem_euclid(360.0), phase)); + } + RingPart::Inner => out.inner = Some((p, phase)), + RingPart::Corner => {} + } + } else { + let new = set.unwrap_or(hue + steps); + // Without a marker there is no color yet, so a set value applies even when it equals `hue`. + if new != hue || (set.is_some() && !marked) { + out.hue = Some((new.rem_euclid(360.0), Live::Released)); + } + } + out +} + /// Hue sectors and saturation rings of the [`segmented_wheel`]: patches 15° of hue by 20% /// saturation wide, the sectors centered on red at 0°. const WHEEL_SECTORS: u32 = 24; diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index e73a41707..04ac7160f 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -192,7 +192,7 @@ pub(crate) fn round_to(v: f64, decimals: i32) -> f64 { /// One step of a numeric field with each modifier, most specific first (a plain pattern would also /// match Shift): ten with Shift, a tenth with Ctrl/Cmd, else one. A Ctrl/Cmd or plain step goes to /// the next multiple of its size ([`step_to_multiple`]); a Shift step adds or takes ten. -const STEPS: [(egui::Modifiers, f64); 3] = [(egui::Modifiers::SHIFT, 10.0), (egui::Modifiers::COMMAND, 0.1), (egui::Modifiers::NONE, 1.0)]; +pub(super) const STEPS: [(egui::Modifiers, f64); 3] = [(egui::Modifiers::SHIFT, 10.0), (egui::Modifiers::COMMAND, 0.1), (egui::Modifiers::NONE, 1.0)]; /// `v` moved `n` steps of `size` (down when `n` is negative): the first step goes to the next /// multiple of `size` in its direction and each further step adds or takes one `size`. By 1, 5.1 @@ -247,9 +247,9 @@ pub(super) fn shown_alike(show: impl Fn(f64) -> String) -> impl Fn(f64, f64) -> /// a weight stops at 0, a miter limit at 1), and a plain step also stops at each of `stops` /// ([`step_through`]). The new value is applied at once, as in /// Illustrator's panels and dialogs, and the new text replaces `buf`, all selected so typing -/// replaces it. Returns the new number when a step was taken. While the pointer is over the focused -/// field the wheel is the field's (the panel under it doesn't scroll); an unfocused field leaves the -/// wheel to the panel and the canvas. +/// replaces it when focused. Returns the new number when a step was taken. While the pointer is +/// over an enabled field the wheel is the field's (the panel under it doesn't scroll), without +/// taking keyboard focus. Arrow keys still require keyboard focus. fn step_value( ui: &Ui, resp: &Response, @@ -261,11 +261,15 @@ fn step_value( ) -> Option { // Memory focus, as the field's highlight and its typing use: `Response::has_focus` also needs the // window to report keyboard focus. - if !ui.memory(|m| m.has_focus(resp.id)) { + if !resp.enabled() { return None; } + let focused = ui.memory(|m| m.has_focus(resp.id)); use egui::{Event, Key, MouseWheelUnit}; let wheel = resp.contains_pointer(); + if !focused && !wheel { + return None; + } // Wheel turns in notches: a line each, or `line` points of a trackpad or a fine wheel; what is // left of a notch carries over to the next turn. let (line, notch_id) = (ui.ctx().options(|o| o.input_options.line_scroll_speed), resp.id.with("wheel")); @@ -278,8 +282,8 @@ fn step_value( let (Event::Key { modifiers, .. } | Event::MouseWheel { modifiers, .. }) = e else { return true }; let Some(&(mods, size)) = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)) else { return true }; let n = match e { - Event::Key { key: Key::ArrowUp, pressed: true, .. } => 1.0, - Event::Key { key: Key::ArrowDown, pressed: true, .. } => -1.0, + Event::Key { key: Key::ArrowUp, pressed: true, .. } if focused => 1.0, + Event::Key { key: Key::ArrowDown, pressed: true, .. } if focused => -1.0, Event::MouseWheel { unit, delta, .. } if wheel => { // Shift may turn the wheel sideways (the Mac does). let d = if modifiers.shift { delta.x + delta.y } else { delta.y }; @@ -325,7 +329,9 @@ fn step_value( let v = within(if stops.contains(&v) { v } else { round_to(v, STEP_DECIMALS) }, bounds); *buf = show(v); ui.data_mut(|d| d.insert_temp(resp.id, buf.clone())); - select_all(ui, resp.id, buf); + if focused { + select_all(ui, resp.id, buf); + } Some(v) } diff --git a/crates/ui-egui/src/workspaces.rs b/crates/ui-egui/src/workspaces.rs index d7590ff3b..7e3c9acc4 100644 --- a/crates/ui-egui/src/workspaces.rs +++ b/crates/ui-egui/src/workspaces.rs @@ -35,6 +35,7 @@ pub struct Workspace { pub dock_collapsed: bool, pub open_panel: Option, pub status_bar: bool, + pub help_bar: bool, /// Tool groups torn off the toolbar into floating strips (built-in workspaces: none). pub floating_flyouts: Vec, /// Floating panel groups in workspaces saved before the canvas joined `docking`; read only @@ -57,6 +58,12 @@ pub struct Workspace { pub docking_collapsed: std::collections::BTreeSet, /// Where the Tools panel floats (built-in workspaces: docked). pub toolbar_pos: Option<[f32; 2]>, + /// The docked Tools panel sits beside the canvas, inside any left-side panels (built-in + /// workspaces and workspaces saved without it: at the window's left edge). + pub tools_beside_canvas: bool, + /// The docked Tools panel stands on the right, left of the panels (built-in workspaces and + /// workspaces saved without it: on the left). + pub toolbar_right: bool, /// Main dock column width in screen points while `docking` is none (#1753). #[serde(default, skip_serializing_if = "Option::is_none")] pub dock_width: Option, @@ -189,6 +196,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace { dock_collapsed: ui.dock_collapsed, open_panel: ui.open_panel.clone(), status_bar: ui.status_bar, + help_bar: ui.help_bar, floating_flyouts: ui.floating_flyouts.clone(), floating_panels: Vec::new(), docking: ui.docking.clone(), @@ -197,6 +205,8 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace { docking_icons: Default::default(), docking_collapsed: Default::default(), toolbar_pos: ui.toolbar_pos, + tools_beside_canvas: ui.tools_beside_canvas, + toolbar_right: ui.toolbar_right, dock_width: ui.dock_width, toolbars: ui.custom_toolbars.clone(), } @@ -217,12 +227,15 @@ pub fn apply(ui: &mut UiState, w: &Workspace) { ui.dock_collapsed = w.dock_collapsed; ui.open_panel = w.open_panel.clone(); ui.status_bar = w.status_bar; + ui.help_bar = w.help_bar; ui.floating_flyouts = w.floating_flyouts.clone(); ui.docking_generation = ui.docking_generation.wrapping_add(1); ui.docking = w.docking.clone().filter(crate::panel_docking::valid); ui.docking_hidden = w.docking_hidden.clone(); ui.docking_hidden_groups = w.docking_hidden_groups.clone(); ui.toolbar_pos = w.toolbar_pos; + ui.tools_beside_canvas = w.tools_beside_canvas; + ui.toolbar_right = w.toolbar_right; ui.dock_width = w.dock_width; crate::state::sanitize_toolbar_pos(&mut ui.toolbar_pos); ui.custom_toolbars = w.toolbars.clone(); diff --git a/crates/ui-egui/tests/safe_mode.rs b/crates/ui-egui/tests/safe_mode.rs new file mode 100644 index 000000000..e47ef9bbe --- /dev/null +++ b/crates/ui-egui/tests/safe_mode.rs @@ -0,0 +1,327 @@ +//! Safe Mode in the app: the choice after a launch that stopped while a plug-in was loading +//! (Disable and Continue, Start Normally, Start in Safe Mode), Help › Safe Mode with Enable on +//! Re-launch, and Safe Mode in the window title. Safe Mode and installed plug-ins are +//! process-wide, so these tests run in their own test binary, one at a time. +// 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::{Path, PathBuf}; +use std::sync::Mutex; + +use serde_json::json; +use vectorcraft_engine::Session; +use vectorcraft_plugins::safe_mode::{self, Item}; +use vectorcraft_plugins::{registry, wat as tpl}; +use vectorcraft_ui_egui::dialogs::safe_mode as ui; +use vectorcraft_ui_egui::panels::appearance; +use vectorcraft_ui_egui::{Services, VectorcraftApp, chrome, dialogs, theme}; + +static LOCK: Mutex<()> = Mutex::new(()); + +/// Safe Mode off, no launch marker, nothing pending, disabled or installed. +fn reset() { + ui::end_launch(); + safe_mode::set_active(None); + safe_mode::forget_previous(); + safe_mode::set_disabled(vec![]); + for p in registry::list() { + registry::remove(p.id()); + } +} + +/// Holds the lock for one test and resets the process-wide state when the test starts and when it +/// ends, so a test that fails leaves the next one as it would find a new process. +struct Fresh { + _guard: std::sync::MutexGuard<'static, ()>, +} + +fn fresh() -> Fresh { + let guard = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset(); + Fresh { _guard: guard } +} + +impl Drop for Fresh { + fn drop(&mut self) { + reset(); + } +} + +fn filter(id: &str) -> Vec { + wat::parse_str(tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#)).unwrap() +} + +fn tmp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("vc-ui-safe-mode-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A plug-in folder holding x and y, as `plugin.install` keeps them. +fn store_with_two(tag: &str) -> PathBuf { + let dir = tmp_dir(tag); + let mut s = Session::new(); + s.attach_plugin_store(dir.clone()); + for id in ["test.ui.x", "test.ui.y"] { + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&filter(id))})).unwrap(); + } + dir +} + +/// A launch of the app after one that stopped while `stopped` was loading (none: Safe Mode from +/// `--safe-mode` when `flag`), with the plug-in folder `dir` and the launch marker folder `launch`. +fn launch(dir: &Path, launch: &Path, flag: bool) -> VectorcraftApp { + for p in registry::list() { + registry::remove(p.id()); + } + let stopped = ui::begin_launch(Some(launch), flag); + let mut session = Session::new(); + session.attach_plugin_store(dir.to_path_buf()); + let mut app = VectorcraftApp::new(session, Services::default()); + ui::after_start(&mut app, stopped); + app +} + +/// A marker left by a launch that stopped while the stored plug-in x was loading. +fn stopped_while_loading_x(launch: &Path, dir: &Path) { + let x = std::fs::read_dir(dir).unwrap().flatten().map(|e| e.path()).find(|p| p.to_string_lossy().contains("test_ui_x")).unwrap(); + let item = json!({"format": "vectorcraft-launch", "version": 1, "item": {"file": x.display().to_string()}}); + std::fs::create_dir_all(launch).unwrap(); + std::fs::write(launch.join("launch-1-dead.json"), item.to_string()).unwrap(); +} + +/// One headless frame of the dialog layer. +fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); +} + +fn done(launch: &Path) { + ui::end_launch(); + safe_mode::set_active(None); + safe_mode::forget_previous(); + safe_mode::set_disabled(vec![]); + let _ = std::fs::remove_dir_all(launch); +} + +fn kind(app: &VectorcraftApp) -> Option<&str> { + app.ui.dialog.as_ref().map(|d| d.kind.as_str()) +} + +#[test] +fn disable_and_continue_loads_every_plug_in_but_the_one_that_was_loading() { + let _f = fresh(); + let dir = store_with_two("disable"); + let launch_dir = dir.join("Launch"); + stopped_while_loading_x(&launch_dir, &dir); + let mut app = launch(&dir, &launch_dir, false); + assert_eq!(kind(&app), Some(ui::STOPPED)); + assert!(safe_mode::active() && registry::list().is_empty(), "nothing loads until the user chooses"); + assert!(chrome::window_title(&app).ends_with("(Safe Mode)")); + frame(&mut app); + dialogs::confirm(&mut app).unwrap(); + assert!(!safe_mode::active() && kind(&app).is_none()); + assert!(registry::get("test.ui.x").is_none() && registry::get("test.ui.y").is_some(), "only the one that was loading is off"); + assert_eq!(chrome::window_title(&app), "VectorCraft"); + assert!(dir.join("disabled.json").is_file(), "kept off at later launches"); + ui::end_launch(); + + // The next launch, which quit normally before: nothing is asked, and x stays off. + let app = launch(&dir, &launch_dir, false); + assert!(kind(&app).is_none() && !safe_mode::active()); + assert!(registry::get("test.ui.x").is_none() && registry::get("test.ui.y").is_some()); + done(&launch_dir); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn start_normally_loads_everything_and_start_in_safe_mode_loads_nothing() { + let _f = fresh(); + let dir = store_with_two("normal"); + let launch_dir = dir.join("Launch"); + stopped_while_loading_x(&launch_dir, &dir); + let mut app = launch(&dir, &launch_dir, false); + app.ui.dialog.as_mut().unwrap().fields.insert("normal".into(), json!(true)); + dialogs::confirm(&mut app).unwrap(); + assert!(!ui::choosing(&app) && !ui::staying_on(&app), "Start Normally ends Safe Mode"); + assert!(registry::get("test.ui.x").is_some() && registry::get("test.ui.y").is_some()); + assert!(safe_mode::disabled().is_empty() && !dir.join("disabled.json").exists(), "Start Normally changes no setting"); + ui::end_launch(); + + stopped_while_loading_x(&launch_dir, &dir); + let mut app = launch(&dir, &launch_dir, false); + assert!(ui::choosing(&app) && !ui::staying_on(&app), "Safe Mode until the user chooses"); + dialogs::cancel(&mut app); + assert!(!ui::choosing(&app) && ui::staying_on(&app), "Safe Mode for the rest of the launch"); + assert!(safe_mode::active() && registry::list().is_empty(), "Start in Safe Mode"); + assert_eq!(safe_mode::source(), Some(safe_mode::Source::Startup)); + assert!(safe_mode::disabled().is_empty(), "nothing turned off"); + ui::end_launch(); + safe_mode::set_active(None); + let app = launch(&dir, &launch_dir, false); + assert_eq!(kind(&app), Some(ui::STOPPED), "Start in Safe Mode leaves the choice to the next launch"); + done(&launch_dir); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_disabled_list_that_cannot_be_saved_says_so() { + let _f = fresh(); + let dir = store_with_two("unsaved"); + let launch_dir = dir.join("Launch"); + stopped_while_loading_x(&launch_dir, &dir); + // A later version's list, which a save keeps as it is. + let later = r#"{"format": "vectorcraft-disabled-plugins", "version": 2, "items": []}"#; + std::fs::write(dir.join("disabled.json"), later).unwrap(); + let unsaved = "Couldn't save the disabled plug-ins for the next launch"; + let mut app = launch(&dir, &launch_dir, false); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.ui.status, unsaved, "Disable and Continue"); + assert!(safe_mode::previous().is_some(), "the next launch asks again"); + assert!(registry::get("test.ui.x").is_none(), "this launch still skips it"); + app.ui.status.clear(); + app.run("help.safeMode", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields["items"][0]["enable"] = json!(true); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.ui.status, unsaved, "Enable on Re-launch"); + assert_eq!(std::fs::read_to_string(dir.join("disabled.json")).unwrap(), later); + done(&launch_dir); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_safe_mode_label_leaves_the_menus_their_room() { + let _f = fresh(); + safe_mode::set_active(Some(safe_mode::Source::Flag)); + let menu_names = ["File", "Edit", "Object", "Type", "Select", "Effect", "View", "Window", "Help"]; + for width in [560.0, 720.0, 1280.0] { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| chrome::app_bar(&mut app, ui)); + out.textures_delta.clear(); + let texts: Vec<_> = out + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(t) => Some((t.galley.job.text.clone(), t.visual_bounding_rect())), + _ => None, + }) + .collect(); + let menus: Vec = texts.iter().filter(|(t, _)| menu_names.contains(&t.as_str())).map(|(_, r)| *r).collect(); + assert!(!menus.is_empty(), "{width}: the menus are drawn: {texts:?}"); + let (start, end) = menus.iter().fold((f32::MAX, f32::MIN), |(a, b), r| (a.min(r.left()), b.max(r.right()))); + assert!(texts.iter().any(|(t, _)| t == "Safe Mode"), "{width}: the label is drawn"); + for (t, r) in texts.iter().filter(|(t, _)| !menu_names.contains(&t.as_str())) { + assert!(r.left() >= end || r.right() <= start, "{width}: {t:?} at {r:?} overlaps the menus from {start} to {end}"); + } + } +} + +#[test] +fn a_dialog_that_replaces_the_choice_leaves_it_waiting() { + let _f = fresh(); + let dir = store_with_two("replaced"); + let launch_dir = dir.join("Launch"); + stopped_while_loading_x(&launch_dir, &dir); + let mut app = launch(&dir, &launch_dir, false); + // Help › Safe Mode (or any other dialog) takes the choice's place before it is answered. + app.run("help.safeMode", json!({})).unwrap(); + assert_eq!(kind(&app), Some(ui::KIND)); + assert!(ui::choosing(&app) && !ui::staying_on(&app), "still waiting for the choice"); + dialogs::cancel(&mut app); + assert_eq!(kind(&app), Some(ui::STOPPED), "the choice opens again once no dialog is open"); + assert!(ui::choosing(&app) && registry::list().is_empty()); + dialogs::confirm(&mut app).unwrap(); + assert!(!ui::choosing(&app) && !safe_mode::active()); + assert!(registry::get("test.ui.x").is_none() && registry::get("test.ui.y").is_some()); + done(&launch_dir); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_safe_mode_flag_asks_nothing_and_loads_nothing() { + let _f = fresh(); + let dir = store_with_two("flag"); + let launch_dir = dir.join("Launch"); + let app = launch(&dir, &launch_dir, true); + assert!(kind(&app).is_none() && ui::staying_on(&app)); + assert_eq!((safe_mode::source(), registry::list().len()), (Some(safe_mode::Source::Flag), 0)); + assert_eq!(app.ui.status, vectorcraft_plugins::Error::SafeMode.to_string()); + assert!(chrome::window_title(&app).ends_with("(Safe Mode)")); + // An object's plug-in effect stays in its Appearance list, marked as skipped. + assert!(appearance::effect_row_label("plugin.org.example.glow").ends_with("(Safe Mode)")); + assert!(!appearance::effect_row_label("stylize.dropShadow").contains("Safe Mode"), "built-in effects still draw"); + // Two plug-ins' effects with the same row name: the tooltip names each one's plug-in. + let (a, b) = (appearance::skipped_effect_tip("plugin.com.a.blur").unwrap(), appearance::skipped_effect_tip("plugin.com.b.blur").unwrap()); + assert!(a.ends_with("\ncom.a.blur") && b.ends_with("\ncom.b.blur"), "{a:?} {b:?}"); + assert_eq!(appearance::skipped_effect_tip("stylize.dropShadow"), None); + safe_mode::set_active(None); + assert!(!appearance::effect_row_label("plugin.org.example.glow").contains("Safe Mode")); + assert_eq!(appearance::skipped_effect_tip("plugin.com.a.blur"), None); + done(&launch_dir); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_safe_mode_flag_launch_leaves_the_stopped_plug_in_to_the_next_launch() { + let _f = fresh(); + let dir = store_with_two("flag-pending"); + let launch_dir = dir.join("Launch"); + stopped_while_loading_x(&launch_dir, &dir); + let app = launch(&dir, &launch_dir, true); + assert!(kind(&app).is_none() && registry::list().is_empty(), "--safe-mode asks nothing"); + assert!(safe_mode::previous().is_some(), "the plug-in is still pending"); + ui::end_launch(); + safe_mode::set_active(None); + + // The next launch, without the flag, asks about it before anything loads. + let mut app = launch(&dir, &launch_dir, false); + assert_eq!(kind(&app), Some(ui::STOPPED)); + assert!(registry::list().is_empty()); + dialogs::confirm(&mut app).unwrap(); + assert!(registry::get("test.ui.x").is_none() && registry::get("test.ui.y").is_some()); + ui::end_launch(); + + // A launch after the choice asks nothing. + let app = launch(&dir, &launch_dir, false); + assert!(kind(&app).is_none() && !safe_mode::active()); + done(&launch_dir); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn help_safe_mode_enables_a_plug_in_for_the_next_launch_only() { + let _f = fresh(); + let dir = store_with_two("help"); + let launch_dir = dir.join("Launch"); + let x = Item { id: Some("test.ui.x".into()), name: Some("X\u{202E}gnp".into()), file: None }; + let mut s = Session::new(); + s.attach_plugin_store(dir.clone()); + assert!(safe_mode::set_enabled(&x, false)); + assert!(s.store_disabled_plugins()); + let mut app = launch(&dir, &launch_dir, false); + assert!(registry::get("test.ui.x").is_none()); + app.run("help.safeMode", json!({})).unwrap(); + assert_eq!(kind(&app), Some(ui::KIND)); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!(d.fields["items"][0]["label"], "Xgnp", "a name can't reorder the dialog's text"); + frame(&mut app); + // Close keeps everything as it was. + dialogs::cancel(&mut app); + assert_eq!(safe_mode::disabled(), std::slice::from_ref(&x)); + app.run("help.safeMode", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields["items"][0]["enable"] = json!(true); + dialogs::confirm(&mut app).unwrap(); + assert!(safe_mode::disabled().is_empty()); + assert!(registry::get("test.ui.x").is_none(), "not loaded during this launch"); + ui::end_launch(); + launch(&dir, &launch_dir, false); + assert!(registry::get("test.ui.x").is_some(), "loaded at the next launch"); + done(&launch_dir); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4e8408cd5..2cb542c6b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -96,18 +96,18 @@ the two can still win that race. | `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item | | `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected. With the Artboard tool it lists the artboard entries (see `docs/mcp.md`), after selecting the artboard there unless it is already selected. While a menu opened over locked objects is open, it has an `Unlock` submenu with one item for each locked object under the pointer, topmost first and at most 20, labeled with the object's name, which runs `layer.setProps {ids: [id], locked: false}`. A locked group gets one item, and the locked objects inside it get none. Hidden objects, objects on hidden, locked or template layers, and in isolation mode objects outside the isolated group get no item | | `ui.tool.select` / `ui.tool.list` | `{tool}` | | -| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse: `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was; the reply lists `selection`, `key` (the key object, null without one) and `tool`. With the Shape Builder or the Live Paint Bucket and a selection of more than 128 segments, the app finds the regions off the UI thread, so a `move` replies before the region under the pointer is highlighted, and the highlight shows in the first frame after the regions are found | +| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse: `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was; the reply lists `selection`, `key` (the key object, null without one) and `tool`. With the Shape Builder or the Live Paint Bucket and a selection of more than 128 segments, the app finds the regions off the UI thread, so a `move` replies before the region under the pointer is highlighted, and the highlight shows in the first frame after the regions are found. With the Shape Builder and such a selection, the app also merges off the UI thread: the reply to the events that end a click or drag comes once the merge is applied. A click or drag made before the regions are found does not wait for them; the app records it and makes it once they are found. Until then the canvas keeps drawing, with the touched regions highlighted and the busy pointer. A click or drag sent meanwhile, with `mods.cmd` too, and the Shape Builder's arrow keys are made after the merge in the order sent, and the reply to such a request comes once its events are made and any merge they start is applied; a request of `move` events alone is answered at once. A command that can edit, select, save or redraw the documents (an edit, an undo, a selection, a save or an export, a tool option, another tool, another document, closing, a dialog's preview, a menu item, a command of the app's own such as `ui.recolorDialog`, the proof and separations views, a preference, added fonts) waits for the merge and the input sent before it, and runs after them; queries that only read, such as `document.inspect`, run at once on the document before the merge | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input | | `ui.move` | `{x, y}` | screen-point pointer move | | `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above | -| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel (Option on the Mac) zooms about the pointer; with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it | +| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls, Cmd-wheel (Ctrl on Windows and Linux) scrolls sideways and Alt-wheel (Option on the Mac) zooms about the pointer (with the `ctrlWheelScrollsSideways` preference off, Cmd-wheel zooms too); with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it. Over the canvas, the wheel preferences in `docs/mcp.md` change what these keys do | | `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?, imageTracePreview?}` | `outline`: View › Outline for the active document only (each document has its own, a document opens in Preview, and `ui.inspect` shows it as `view.outline`; an error without a document). `imageTracePreview`: the Image Trace panel's Preview (`ui.inspect` shows it as `ui.image_trace_preview`) | | `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` or `{fields: [{field, value}…]}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`), `__` fields, `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes | | `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. The inline PNG is capped at 64 MiB: with a `path` the file is still written and `pngBase64` is omitted when the image is larger, while a path-less `data: true` request fails if either the raw RGBA image or the encoded PNG exceeds the cap. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | | `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?, 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). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read 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. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.save` refuses a key that no save format takes, beside the path or in `options`, the same way. `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 | @@ -126,7 +126,7 @@ the two can still win that race. | `engine.execute` `file.exportSelection` | `{multiple?}` | File › Export Selection…: collects the selected objects as one asset (`assets.add`; one per object with `multiple: true`) and opens `exportForScreens` on its Assets tab with just them checked → `{assets}`. `document.exportSelection` still exports the selection in one go, without the dialog | | CSS Properties panel | `window.panel {panel: "cssProperties"}`; `engine.execute` `css.copy` / `css.exportFile` | the panel shows `css.selection` of the selection (after Generate CSS, while nothing is selected, `css.generate`); its menu holds the options the commands take (`units`, `position`, `dimensions`, `unnamed`, `rasterize`). `css.copy {scope?, …options}` copies the CSS to the clipboard → `{css, rules}`; `css.exportFile {path?, scope?, …options}` writes `css.export` to `path` (else a picked file; the web downloads it) with the pictures of rasterized art next to it → `{path, rules, images, warnings}`, and the status bar shows the first of `warnings` as Export As shows an export's notes | -| `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF or `.ai` with several pages or a password, or placing one (`file.place` without `page`), opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog | +| `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF, `.ai` or `.ait` with a password, or one with several pages unless it is a `.ai` or `.ait` with its editing data or carries its VectorCraft document (those open whole), or placing (`file.place` without `page`) any of them with several pages or a password, opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog | Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatchOptions` dialog for a colour swatch (fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`lab`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object, @@ -213,8 +213,11 @@ 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 -a path it asks for one); see [plugins.md](plugins.md). +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); one that needs approval opens the `pluginInstall` dialog, which only a person can confirm +(`ui.dialog.confirm` and injected input are refused); see [plugins.md](plugins.md). Overprint Black: the Edit → Edit Colors → Overprint Black… menu item opens a `command` parameter dialog for `edit.colors.overprintBlack` (fields `remove`, `percentage`, `fill`, `stroke`, `includeCmyBlacks`, @@ -244,6 +247,11 @@ Color Themes panel (`window.panel {panel: "colorThemes"}`): its Create tab makes My Themes tab lists the saved themes; every action runs a command (`colorTheme.save`, `colorTheme.delete`, `colorTheme.addToSwatches`, `swatch.newGroup` for the theme being made), and `colorTheme.list` reads the library. +Color Wheel panel (`window.panel {panel: "colorWheel"}`): a hue ring around a saturation and brightness square. A +pick runs the commands the Color panel's sliders run: `paint.setFill` or `paint.setStroke` with `keepModel`, +`paint.editGradient` for a gradient's selected stop, and `paint.freeform.setPoint` for a freeform gradient's selected +point (the first stop or point when none is selected). + Edit Colors dialogs: `ui.colorBalanceDialog` opens Adjust Colors (`colorBalance`: fields `mode` `gray`/`rgb`/`cmyk`/ `global`, the channels `r` `g` `b` / `c` `m` `y` `k` / `gray` / `tint` (global mode) from −100 to 100, `convert`, `fill`, `stroke`, `preview`) and `ui.saturateDialog` opens Saturate (`saturate`: `intensity` −100..100, `preview`). Both preview on the @@ -328,7 +336,19 @@ not commands, so drive them with `ui.click` if needed. Close runs `app.quit` (th modified documents), and empty bar space and the 5 pt window edges move and resize the window. Preferences › User Interface › System Title Bar (`systemTitleBar`, read when the app starts) gives the window its OS decorations back instead: the OS title then shows the active document (`*name — VectorCraft` with unsaved changes) and the app bar -drops its brand mark. On macOS the window buttons are centred on the app bar; the web build is unchanged. +drops its brand mark. On Linux the app bar also drops its brand mark whenever the desktop draws the window's frame, with +System Title Bar on or off. On macOS the window buttons are centred on the app bar; the web build is unchanged. + +On Linux and the BSDs, Preferences › User Interface › VectorCraft Title Bar on Every Desktop (`appTitleBar`, read +when the app starts, off by default) turns off the desktop's frame and uses the app bar as the title bar in the +sessions where the desktop would draw the frame: X11 sessions, Wayland compositors that draw server-side decorations +such as KDE Plasma, and a window opened through XWayland. System Title Bar and `VECTORCRAFT_TITLEBAR` (`app`, or +`system` or `desktop`) take precedence over it. The checkbox is dimmed while either applies, and a note under it names +each one that applies. `VECTORCRAFT_TITLEBAR` also takes precedence over System Title Bar for the run it is set in: +`app` keeps the app bar as the title bar with System Title Bar on. Only the values `app`, `system` and `desktop` take +precedence (case-sensitive, surrounding spaces ignored); any other value, such as `unknown`, is ignored and does not +override System Title Bar. While the variable is set to one of those values, the Preferences dialog dims +System Title Bar and its `__titlebarFromEnv` field is `true`. Fill/Stroke chips and panel shortcuts: the Control bar's and Properties' Fill and Stroke chips bring their proxy forward (`paint.toggleActive {fill}`) and open a popover with the Swatches panel (Shift-click: the Color panel's mixer); @@ -428,7 +448,9 @@ below). Dragging a tab, a group's drag bar or an icon out of the dock and droppi floats it; dropping it on a group's tabs, on a group's upper or lower edge or beside a column docks it there. On the desktop each floating window is an OS window of its own; on the web and on Wayland it floats inside the app window. `ui.toolbar_pos` in `ui.inspect` is the Tools panel's corner (null: docked); `window.panel.float` and -`window.panel.dock` with `panel: "tools"` float it at `x`, `y` or dock it on the window's left edge. +`window.panel.dock` with `panel: "tools"` float it at `x`, `y` or dock it on the window's left edge. With +`window.toolsBesideCanvas {on?}` (Window › Toolbars › Keep Tools Beside the Canvas, off by default; `ui.tools_beside_canvas`) +the docked Tools panel sits between left-side panels and the canvas, and `window.panel.dock` docks it there. Flatten Transparency: `ui.flattenTransparencyDialog` opens the `flattenTransparency` dialog for the selection (fields `preset`: a preset name, setting it loads that preset's options; the option keys of @@ -666,9 +688,11 @@ with. DXF Options: Export As… with the DXF format (`file.exportAs {format: "dxf"}`, then the save dialog), or `ui.dxfOptionsDialog {path?, …document.exportDxf options}` for agents, opens the `dxfOptions` dialog. Its fields are -`document.export` params: `version`, `unit` (a unit name such as `Millimeters`), `scale`, `scaleLineweights`, `colors` -(`8`, `16`, `256`, `true`), `rasterFormat` (`png`, `jpeg`), `preserve` (`appearance`, `editability`), `selectedOnly`, -`alterPaths`, `outlineText`, plus `path` and the artboard choice. `ui.dialog.confirm` checks them (a bad value, or +`document.export` params: `dxfPreset` (`custom`, `cnc`), `version`, `unit` (a unit name such as `Millimeters`), `scale`, +`scaleLineweights`, `colors` (`8`, `16`, `256`, `true`), `rasterFormat` (`png`, `jpeg`), `preserve` (`appearance`, +`editability`), `selectedOnly`, `alterPaths`, `outlineText`, plus `path` and the artboard choice. Setting `dxfPreset` to +`cnc` sets the fields it sets (see `document.exportDxf`), and `dxfPreset` reads `custom` again once one of them is +changed; setting it to `custom` puts back the defaults. `ui.dialog.confirm` checks them (a bad value, or Export Selected Art Only with nothing selected, keeps the dialog open), remembers them for the next DXF Options and writes the file (asking for a path when there is none). Export As also lists DWG, greyed out: setting its `format` to `dwg` shows, and on confirm answers, the hint to export DXF instead. diff --git a/docs/development.md b/docs/development.md index f7dd5ac59..c6523bd03 100644 --- a/docs/development.md +++ b/docs/development.md @@ -118,7 +118,7 @@ A file opened from the file manager (double-click, Open With, a drop on the exec ## Linux: Wayland and X11 -On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** and restart to use winit's font-free Adwaita window controls (`wayland-csd-adwaita-notitle`); the existing custom title bar remains the Linux default. +On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** and restart to use winit's font-free Adwaita window controls (`wayland-csd-adwaita-notitle`); the existing custom title bar remains the Linux default. On X11, on a Wayland compositor that draws server-side decorations such as KDE Plasma, and in a window opened through XWayland, enable **Preferences › User Interface › VectorCraft Title Bar on Every Desktop** and restart to use the app bar as the title bar instead of the desktop's frame. `VECTORCRAFT_TITLEBAR=app`, `=system` or `=desktop` chooses the frame for one run and takes precedence over System Title Bar and VectorCraft Title Bar on Every Desktop. The variable is case-sensitive and surrounding spaces are ignored; any other value, such as `unknown`, is ignored and does not override System Title Bar. The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature: diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 6fe07e086..8f90ce5f4 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -1,6 +1,6 @@ # Effects parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Extrude & Bevel, Inflate and Rotate apply to groups and layers, their objects turning about one shared axis (#4506); the Transform effect's copies of a path with a fill and a stroke (or several fills and strokes, an even-odd fill, or a partly transparent one) are drawn each above the one before, as Expand Appearance makes them (#5920); Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap, as type often draws a glyph, before building a solid; previously: Extrude & Bevel, Inflate and Rotate apply to groups and layers, their objects turning about one shared axis (#4506); the Transform effect's copies of a path with a fill and a stroke (or several fills and strokes, an even-odd fill, or a partly transparent one) are drawn each above the one before, as Expand Appearance makes them (#5920); Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x) Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue (`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it @@ -11,9 +11,9 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( | Group | Illustrator | VectorCraft | % | Remaining (h) | |---|---:|---:|---:|---:| -| Illustrator effects (vector and stylize) | 50 | 49 | 98% | 4–8 | +| Illustrator effects (vector and stylize) | 50 | 50 | 100% | — | | Photoshop effects (raster, Effect Gallery) | 57 | 57 | 100% | 1–2 (Effect Gallery thumbnails and in-dialog preview) | -| 3D and Materials | 4 (+3 Classic) | 4 (initial: Extrude & Bevel, Revolve, Inflate, Rotate) | ~25% | 25–45 | +| 3D and Materials | 4 (+3 Classic) | 4 (+1 Classic; initial: Extrude & Bevel, Revolve, Inflate, Rotate, Extrude & Bevel (Classic)) | ~25% | 25–45 | | Beyond Illustrator: Effect › Color Adjustments (6), effect plug-ins (WebAssembly) | — | 6 + plug-ins | — | — | ## Illustrator effects @@ -27,7 +27,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( | Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 13/13 | Trap traps only solid fills (gradients and patterns hide what is below them but don't trap) and spreads each colour's visible area as a whole; the public page gives no default values, so Trap's 0.25 pt, 100% and 40% are VectorCraft's own | | Rasterize | Rasterize… (as a live effect) | 1/1 | — | | Stylize | Drop Shadow, Feather, Inner Glow, Outer Glow, Round Corners, Scribble | 6/6 | Scribble Options' Settings presets | -| SVG Filters | Apply SVG Filter… and the filter list | 0/1 | the whole submenu (SVG filters on import round-trip already) | +| SVG Filters | Apply SVG Filter… and the filter list | 1/1 | The submenu lists the document's own filters: VectorCraft ships no default filters. A filter that is the object's last effect is written to SVG as a `` element; otherwise SVG export draws the object as an image. A filter on a turned or skewed object in an imported SVG is left out with a warning | | Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes; Arc is a circular arc in the object's own proportions (#1578) | Document Raster Effects Settings exists. When the 64 million pixel cap on an effect image lowers @@ -179,20 +179,21 @@ VectorCraft choices; an original approximation, never compared with another appl | Item | Ours | |---|---| | Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | -| Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | +| Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | | Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; no materials, shadows or ray tracing | -| 3D (Classic): Extrude & Bevel, Revolve, Rotate | none | +| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic), Rotate (Classic) and Map Art: none | | Turntable (30.0 beta) | none | -| Export 3D objects (OBJ, USDA, glTF) | none | +| Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) | The `three-d` crate (depends on `geom` and `pathops`, with no UI or GPU dependencies) builds, rotates, projects, shades and depth-sorts the surfaces; the effects crate turns them into art. What's left (materials and shadows, ray-traced rendering, -angular cut caps, exact visibility, 3D Classic, Turntable, 3D export) is 25–45 h. +angular cut caps, exact visibility, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable) is 25–45 h. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap (a glyph's arm across its stem) before building a solid, so the solid follows the shape as it is filled; others are built as drawn ([three-d.md](three-d.md)); scores and estimates unchanged | | 2026-11-06 | minor | Extrude & Bevel, Inflate and Rotate apply to a group or layer: a solid for every path, compound path and type in it, all turned about one shared axis as the public documentation describes for groups ([three-d.md](three-d.md#groups), #4506); scores and estimates unchanged | | 2026-11-06 | minor | The Transform effect's copies of a path or compound path whose fills and strokes stack (a fill and a stroke, several of either, an even-odd fill, or a partly transparent or blended one) are drawn each as an object of its own above the one before, as Expand Appearance makes them, instead of one path of every copy whose fill lay under every copy's stroke (#5920). A single opaque stroke or nonzero fill still draws as one path. Counts unchanged. | | 2026-11-04 | minor | Expand Appearance of a path or compound path whose Transform effect makes copies gives a group of one object per copy, each with the path's fills and strokes, instead of one compound path of every copy; copies of copies nest (#5612). Counts unchanged. | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 805bbc63a..32478e930 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 (EPS and `.ai` editing data: a transparency set inside a clipping group stays after its `Q`; 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-07 · **Change:** minor (EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; 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 @@ -17,9 +17,11 @@ from the public specifications, and users' own `.ai` files are used locally only | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| +| Formats opened or placed | ~30 | 30 | missing: FreeHand, CorelDRAW, DWG, PICT, PS | +| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, artboards) or as legacy version 7 (layers and groups, one artboard per file) | | Formats opened or placed | ~30 | 29 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, PICT, PS | | Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, gradients, patterns, images, type, symbols, artboards, transparency, opacity masks, clipping) 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 | +| Formats exported | ~16 | 18 | missing: DWG; extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | | **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting | EPS editing-data follow-up (2026-10-16): both mesh-corner layouts (with and without opacity) @@ -48,7 +50,13 @@ private editing data. VectorCraft: A multi-layer mask (`Mb` … `Md` … `MB`) comes in as a clipping group in each layer it covers, each clipped by a copy of the mask's path. An opacity mask's art of one shown object, other than a guide or a clipping path, opens as that object in the - editing data of a `.ai`, EPS or PDF; other art opens in a group. + editing data of a `.ai`, EPS or PDF; other art opens in a group. Art in the mask that sets no paint of its + own paints with the paint before the mask's dictionary, as the writer leaves out paint that doesn't change. + Type from the text document keeps auto leading as auto, each line taking the paragraph's share + (Auto Leading) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)), + and the paragraph's hyphenation options and tab stops. Area type of one + frame whose last lines the file sets but which don't fit here (the fallback font, or a descent + past the frame's bottom) may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines, with a warning when it does. RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb` import option, on for editor documents, off for PDFs), so `.ai` files tagged with a non-sRGB RGB profile open with different, unconverted RGB colours than before. @@ -94,13 +102,21 @@ private editing data. VectorCraft: group's effects change its members' outlines, a member's strokes are plain lines, and its fills are not pattern fills written as tiles or gradients that fit their object. The look holds the object's vector art, without the shadow, glow or feather. Other objects with live effects are written as their vector art with their geometry effects applied, and their other effects, such as - Drop Shadow, the glows and Feather, are left out with a warning. Graphic styles and links to them are left out with a warning. No art style is written on a large - canvas. + Drop Shadow, the glows and Feather, are left out with a warning. A path, compound path or group linked to a graphic style names it when it has the style's + appearance and the style is written; other links to graphic styles, those on a styled group's members included, are + left out with a warning. The Graphic Styles panel's styles are written in its order, with their fills, strokes, transparency and effects (Warp, + Transform, Free Distort, Pucker & Bloat, Twist, Zig Zag and Gaussian Blur too). Styles with gradient or pattern fills, + with fills, strokes or effects the reader doesn't read back, with a name another style has, with names over 1,024 + bytes and past 4,096 styles are left out with a warning. A panel with no style written opens with a new document's + styles, with a warning. No art style is written on a large canvas. Point type, area type (with its rows, columns, gutters and first baseline, threaded frames included) and type on a path are written as text: a text object for each, naming its story in the text document, with the characters, fonts, sizes, leading, tracking, baseline shift, scales, solid fills and strokes, capitals and paragraph alignment, indents, spacing and direction; other character - and paragraph attributes are left out with a warning. Type in opacity masks, pattern tiles, symbols and art cut to a + and paragraph attributes are left out with a warning. Each run's baseline direction is written in the form the reader + below reads it: 1 when Standard Vertical Roman Alignment is on, 2 when it is off and 3 for Tate-chu-yoko; the + document's defaults and the normal character style carry 1. A run with both Tate-chu-yoko and Standard Vertical Roman + Alignment is written as Tate-chu-yoko, with the type attributes warning. Type in opacity masks, pattern tiles, symbols and art cut to a clipping object, mirrored type, turned or unevenly scaled area type and type on a path, area type in an open frame, type on an open path whose ends meet, area type that flows around a text wrap, type of only white space, threads of type on a path, threads whose frames differ in orientation, type and stories past the sizes VectorCraft's reader reads, and @@ -153,7 +169,7 @@ private editing data. VectorCraft: 14; #868: round trip with Illustrator 2018 loses layers and groups), some files 4× slower to open since the editing data is read whole (#758), live effects that are not read or have settings that are not read, and brushes come in as their drawn look (#637; pattern fills are read since #1025, and symbols and their instances - open live); legacy `.ai` without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868; guides + open live, as do the symbols of a symbol library without layers); legacy `.ai` without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868; guides open as ruler guides and guide paths since #5057 (#779). Type under a Warp effect is compared with the art saved with it: type that draws as that art stays editable with the effect live, and type that draws differently, or that the file's limit of checks has no check left for, opens @@ -213,26 +229,50 @@ 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). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). 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). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its 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). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. 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; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, 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). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). 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; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its 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; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; 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). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). 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; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its 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; a filled clipping path keeps its fill, which paints under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, 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). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). 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; a filled clipping path keeps its fill, which paints under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its 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 replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack, 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). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). 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; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its 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; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). 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 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; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). 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 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; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; 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). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its 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; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, placed PDF and `.ai` pages as placed documents of the [page and Crop to box](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/adobe-pdf-placement-options.html) their dictionary names (`AI11 PlacedPDFPageIndex`, `AI11 PlacedPDFCropTo`; a value VectorCraft doesn't read, or a binding to an image variable, leaves a linked image), guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile on a Stroke Offset stroke or written as each side's widths) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way 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), a variable-width stroke with one centered width array, dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its 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; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). 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). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its 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). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, 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). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; 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). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips; 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). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips; 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack, 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). 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; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its 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; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, placed PDF and `.ai` pages as placed documents of the [page and Crop to box](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/adobe-pdf-placement-options.html) their dictionary names (`AI11 PlacedPDFPageIndex`, `AI11 PlacedPDFCropTo`; a value VectorCraft doesn't read, or a binding to an image variable, leaves a linked image), guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way 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). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way 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). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way 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). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips; 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way 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). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips; 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way 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. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way 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). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way 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). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; 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), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way 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 or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; 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. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +A run of type in the editing data whose baseline direction is 1 opens with Standard Vertical Roman Alignment (https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html) on, which is VectorCraft's own mapping: its half-width characters stand upright in vertical type, and horizontal type keeps the mark with no change to its layout. A run whose baseline direction is 2 or 3 (Tate-chu-yoko), or that names none in the run, its character style or the document's defaults, opens with it off. The scripting reference lists 1, 2 and 3 as the Standard, vertical rotated and Tate-chu-yoko baseline directions (https://web.archive.org/web/20170621180804/https://wwwimages.adobe.com/content/dam/Adobe/en/devnet/illustrator/sdk/CC2015_3/Illustrator%20VBScript%20Reference.pdf, page 256). A Scatter brush in the editing data whose last 15 numbers start with 1 comes into the Brushes panel in Tints. Each brush art section is read without its frame: an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt), closed, or open with its first corner again at the end. A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group. +An object in a pattern's art or in a brush's art takes the file's art styles in the same way as an object on a layer: with a style VectorCraft reads, it comes in as itself with its effects live, not as the look it draws. An object in a pattern's art with a style in the file's style list keeps that graphic style's id. An art styles section inside a symbol's, a pattern's or a brush's art is skipped. + +Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets) are read as Windows-1252 unless `legacyNameEncoding` (`convert --legacy-name-encoding`) picks `macintosh`, `windows-949`, `shift_jis`, `gbk` or `big5`; the open result's warnings list the first three such names as read and count the rest. The text values of data sets are read in the same encoding. + ## Format by format | Format | Illustrator | Ours: open/place | Ours: save/export | Fidelity and tests | @@ -241,7 +281,7 @@ A Scatter brush in the editing data whose last 15 numbers start with 1 comes int | Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) | | Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure with template layers and ruler guides, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus | | PDF | open, place, save | yes (layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160) | -| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type not read; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | +| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined Unicode CMap (`UniJIS-UCS2-H`), set as type in the font of the CIDFont's name; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | | SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`) | | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open | @@ -251,7 +291,8 @@ A Scatter brush in the editing data whose last 15 numbers start with 1 comes int | PSB | open | yes (merged image) | no | | | PNG / JPEG / GIF / BMP / TIFF / Targa | open, place, export | yes (PNG: `.png` and `.pns`; BMP: `.bmp`, `.dib` and `.rle`; 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) | | -| HEIC / HEIF | open, place | no | no | | +| 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 | yes (HEVC-coded still images: single pictures and grids of tiles, 8 to 10 bits, with an alpha image; image sequences, other codings and grids of cropped tiles are refused) | no | `.heic`, `.heif`; decoded by heic-rs in the engine's default `heif` feature (a build without it refuses the files with a message); the container's rotation, mirror and crop are applied; without an `nclx` box the HEVC stream's video signal type gives the matrix and range; an RGB ICC profile, or Display P3 with the sRGB curve named by an `nclx` box or, without a color box, by the stream, is converted to sRGB; EXIF resolution tags size the image, or 72 ppi; bounded decoding; `tests_heifread.rs` | | 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` | | JPEG 2000 | open, place | yes (JP2 and raw codestream images; multiple codestreams and JPX composition or layers are refused) | no | `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c`, `.jpc`; resolution uses the valid `resd` (display) value on each axis, or else the valid `resc` (capture) value, or 72 ppi; bounded decoding; `tests_jp2read.rs` | | PICT | open, place (legacy) | no | no | rarely used | @@ -263,7 +304,7 @@ A Scatter brush in the editing data whose last 15 numbers start with 1 comes int | Word `.docx` | place | place (area type with the document's styles and its fonts, size, bold, italic, underline, strikethrough, caps, superscript and subscript, color, alignment, indents and paragraph spacing) | no | `tests/textimport.rs`, `place/docx.rs`; fuzzed (`text_import_fuzz.rs`); list numbers, pictures and text boxes are left out | | Word `.doc` | place | place (area type with the same formatting as `.docx`, from the style sheet's styles and direct formatting; Word 97 and later) | no | `tests/textimport.rs`, `place/doc.rs`; fuzzed (`text_import_fuzz.rs`); pictures, headers, footers, notes, comments and text boxes are left out with a warning; encrypted and fast-saved documents are refused | | CSS | export (CSS Properties) | — | yes | | -| OBJ / USDA / glTF | 3D export | — | no | needs 3D (gap G3) | +| OBJ / USDA / glTF | 3D export | — | yes, also USDZ (a 3D asset of Asset Export: `assets.add3D`, `assets.export3D`) | each solid of an object with Extrude & Bevel, Revolve, Inflate or Rotate is a triangle mesh of its faces before projection, at the object's 3D rotation, y up, with a two-sided material in its paint's color; glTF 2.0 is one `.gltf` with its buffer embedded, in meters; USDA and OBJ are in points (USDA says its `metersPerUnit`), OBJ with an `.mtl`; USDZ stores the USDA uncompressed on a 64-byte boundary; tests in `crates/three-d/src/mesh/tests.rs`, `crates/engine/src/cmd/fileio/mesh3d/tests.rs` and `crates/engine/src/tests/assets_3d.rs` | | Affinity `.af`, `.afdesign`, `.afpub` | no | yes (beyond Illustrator) | no (#665) | 51 pinned files, [affinity-validation.md](affinity-validation.md) | | Clipboard: PDF, SVG, PNG, EMF, text | yes | yes | yes | copied files paste as art; cells copied from a spreadsheet (tab-separated text) paste as a table of point type over gridlines, at most 1000 rows, 256 columns and 20,000 cells (#5296) | @@ -279,11 +320,28 @@ A Scatter brush in the editing data whose last 15 numbers start with 1 comes int | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | EPS import: a CID-keyed font over a predefined Unicode CMap the file doesn't define (Adobe's public CMap resources named `Uni…-UCS2`, `-UTF8`, `-UTF16` or `-UTF32`, `-H` or `-V`: [cmap-resources](https://github.com/adobe-type-tools/cmap-resources)), found by its `CIDFont-CMap` name (`/Ryumin-Light-UniJIS-UCS2-H findfont`, the usual name of a CID-keyed font: its CIDFont's and CMap's names joined by a hyphen) or made with `composefont`, sets its codes' characters as live type in the font of its CIDFont's name, and `stringwidth` measures them. Such type was read as Latin-1 codes, or stopped the program at `composefont`. Vertical CMaps are set horizontally, and the show operators' extra spacing isn't kept; CIDFonts over other CMaps the file doesn't carry still draw nothing, with a warning. | +| 2026-11-07 | minor | EPS import: CMap mappings to base fonts (`beginbfchar` … `endbfchar`, `beginbfrange` … `endbfrange` after `usefont`: [Adobe Technical Note #5099](https://adobe-type-tools.github.io/font-tech-notes/pdfs/5099.CMapResources.pdf); PLRM 3rd ed., §5.11.4) are read instead of dropped: a code mapped to a character code (a range's codes counting up from its last byte) or to a glyph name (one, or an array of them) draws that glyph of the base font it names, a Type 3, Type 42 or embedded Type 1 font's own glyph or, in a font found by name, its character as type. Such codes took font 0's CID 0 and drew nothing. | +| 2026-11-07 | minor | EPS import: a FontSet's Compact Font Format data (`/name n StartData` in the FontSetInit procedure set: PLRM 3rd ed., §5.8.1; [Adobe Technical Note #5176](https://adobe-type-tools.github.io/font-tech-notes/pdfs/5176.CFF.pdf)) is read instead of skipped: each CID-keyed font in it (a top DICT with `ROS`) is defined as a CIDFont of its name, and type in a CID-keyed font over it draws its glyphs, picked by CID through the charset and drawn from their Type 2 charstrings with their FDSelect subfont's subroutines and widths. Name-keyed fonts in a FontSet are still set as type by name. | +| 2026-11-07 | minor | EPS import: `kshow` (PLRM 3rd ed., chapter 8) runs its procedure between each two characters of type in a base font (not a Type 3 or composite font), with the two character codes, instead of only between a Type 3 font's glyphs: where it moves the current point (kerning), the next character follows, kept on the live type as tracking, and what it draws is drawn over the type. | +| 2026-11-07 | minor | EPS import: an embedded Type 1 font's encrypted part (`eexec`, [Adobe Type 1 Font Format](https://adobe-type-tools.github.io/font-tech-notes/pdfs/T1_SPEC.pdf), chapter 7) is no longer only skipped: its charstrings are read, and the font dictionary gets the `CharStrings` dictionary the part would have given it. Its type then takes its characters from its glyph names (the standard and ISO Latin-1 encodings by their codes' names, PLRM 3rd ed., Appendix E), its widths move the current point and space the live type as tracking (as Type 42 fonts do), `stringwidth` and `charpath` read its glyphs, glyphs whose names say no character are drawn from its outlines, and a composite font's Type 1 descendants draw their glyphs. A font found by name, re-encoded with glyph names at other codes (codes 1 to 12 for `T`, `r`, `a`… in pdftocairo's subsets), sets the characters its names say instead of its codes read as Latin-1 (such type was left out). | +| 2026-11-07 | minor | EPS import: CID-keyed fonts (PLRM 3rd ed., §5.11; [Adobe Technical Note #5014](https://adobe-type-tools.github.io/font-tech-notes/pdfs/5014.CIDFont_Spec.pdf)) draw from the font data the file carries instead of stopping the program at `composefont` or the CIDInit operators: CMaps built with `begincmap` … `endcmap` (codespace ranges, CID and notdef mappings, `usefont`, `usecmap`; mappings to base-font codes are not read) and the predefined `Identity-H` and `Identity-V`; `composefont` and `FMapType` 9 fonts, whose codes take as many bytes as their codespace range says, an invalid or unmapped code falling back as §5.11.5 says; Type 0 CIDFonts' Type 1 charstrings, read by `StartData` (binary or hexadecimal) or from a `GlyphDirectory`, with their FDArray dictionaries' matrices and subroutines (`Subrs` or the binary data's subroutine table); Type 2 CIDFonts' TrueType glyphs through `CIDMap` (strings, a dictionary or an offset); `Metrics` widths; vertical writing mode set downwards. A CIDFont the file doesn't carry draws nothing, with a warning; FontSet (Compact Font Format) data is skipped. | +| 2026-11-07 | minor | EPS import: a plain Type 42 font that carries its TrueType font ([Adobe Technical Note #5012](https://adobe-type-tools.github.io/font-tech-notes/pdfs/5012.Type42_Spec.pdf)) gives each code its character by its glyph name (single characters, `uniXXXX`, the standard encoding's and Windows-1252's names) or, when the name says nothing (`/g12`), by the glyph `CharStrings` gives it in the font's own character map or `post` names, instead of reading the code as Latin-1 (a Euro at code 128 was dropped); the font's own widths move the current point (`stringwidth`, `show`), `charpath` outlines its glyphs, and a string with a glyph that stands for no character is drawn from the font's outlines. Live type keeps the spacing the file gives it: where the show operator's numbers (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`: PLRM 3rd ed., chapter 8) or the embedded font's widths differ from the installed or fallback font's advances, each character's tracking makes up the difference, as PDF import does, so justified type in a font that isn't installed no longer drifts (pdftocairo and pdftops EPS of a justified PDF page set in a missing font: 10.2% and 9.0% of the page differing from Ghostscript's render, now 6.2% and 5.8%). Type under `nulldevice` no longer shows. | +| 2026-11-06 | minor | EPS import: the buffers an image decoder makes for itself (its sample planes, its copies of the strings a procedure data source hands back, and the split buffers of an interleaved masked image) stop counting against the PostScript reader's 1 GiB memory budget once the image is decoded, as they are freed then; the pixels kept, and anything the program's own procedures allocate, still count. A user's Illustrator 25.2 EPS with 71 CMYK images (used locally, not committed) used to stop with "the program uses too much memory" and come in as its screen preview; it now opens with its art. The budget itself is unchanged. Scores and estimates unchanged. | +| 2026-11-07 | minor | EPS import: a sampled function (type 0) of two or more inputs took the nearest sample, so a function-based shading (type 1) over one came in as flat blocks; it is now interpolated between its samples (multilinearly), and with `Order 3` by cubics through them (Catmull-Rom, kept in the function's range), as the PostScript Language Reference (3rd ed., §3.10.1, type 0 functions) defines. An axial or radial shading whose function is a linearly interpolated sampled function of one input has a stop at each sample, not 33 evenly spaced ones. Tests `sampled_functions_interpolate_linearly_or_by_cubics`, `mesh_and_function_shadings_become_gradient_meshes`; a 2 × 2 function-based shading is 1.2% from Ghostscript's render (it was 45.9%); scores unchanged | +| 2026-11-07 | minor | EPS and `.ai` type from the text document keeps its paragraphs' tab stops (positions and alignments; leaders aren't read), at most 1,024 a paragraph and 2^20 a story, past which the default stops apply with a warning. A user's `.ai` (used locally, not committed) whose price lists came in on the default stops now lays them out as its page has them (its type 0.8% from its page, from 1.5%; what differs is hyphenation in Hungarian and type in a font that isn't installed). Scores and estimates unchanged. | +| 2026-11-07 | minor | EPS and `.ai` type from the text document: auto leading stays auto at the paragraph's share (it was the first two lines' distance for every line, so 10 pt lines under a 15 pt heading came in 18 pt apart), the paragraph's hyphenation options are read, and area type of one frame whose last lines the file sets in too short a frame for them here may get a frame taller by its lines' deepest descent plus 0.05 pt, when that shows more of them. A user's `.ai` (used locally, not committed) whose type differed from its page by 6.6%, so that it opened from its PDF part, now opens from its layers (4.9%; type with tab stops in right-aligned paragraphs still differs, a separate issue) (#6137). Scores and estimates unchanged. | +| 2026-11-06 | minor | EPS and `.ai` editing data: a path or compound path whose art style stacks several fills and strokes (solid colours and gradients, each with its own visibility, opacity and blend mode, under effects any object takes) comes in as itself with that appearance instead of its drawn look. Each gradient must be its own swatch, placed as the drawn look places it; a stack that repeats a swatch, or whose look doesn't paint its gradients, keeps the drawn look, as do fills and strokes with effects of their own. | +| 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 | EPS import: composite (Type 0) fonts with the 8/8 mapping (`FMapType` 2: PLRM 3rd ed., §5.10) draw a Type 42 descendant's glyphs from the TrueType outlines its `sfnts` carry (Adobe Technical Note #5012), spaced by their own widths or the show operator's numbers, and `charpath` and `stringwidth` read them; descendants found by name set their characters as type at the composite font's size instead of a thousand times it. EPS from `pdftops` of a PDF with embedded TrueType fonts showed nothing, or one black shape, where its type was. | +| 2026-11-07 | minor | EPS and `.ai` editing data: opacity mask art that sets no paint of its own paints with the paint before the mask's dictionary, not the black a dictionary's state starts with. A logo of white letters masked by a rectangle with no colour no longer opens hidden: a user's `.ai` (used locally, not committed) shows its six logos. Scores and estimates unchanged. | +| 2026-11-06 | minor | EPS and `.ai` editing data: an art style's stroke aligned inside or outside its path (Stroke panel › Align Stroke, written as its own `Adobe Stroke Offset` filter) comes in as a stroke with that alignment, so objects whose style has one keep the style live instead of coming in as their drawn look. Without its inside/outside switch the drawn look stays. | | 2026-11-06 | minor | EPS and `.ai` editing data: `Q` restores the painting state but not the transparency (`Xy`), which the writer only sets when the next object's differs from the last one written. A Screen glow followed by a clipping group set back to Normal no longer leaves the type after the group in Screen: two users' Illustrator 26.5 and 30 EPS posters (used locally, not committed) now show their gold heading and crescent, and both open from their layers (2.5% and 2.7% from the page before, under the limit after). Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: a blend whose spine was never edited, written with its keys and no spine part, comes in as a live blend along the straight lines between its keys' centres (in its record's order) instead of as its drawn steps. Checked on six users' Illustrator EPS files (used locally, not committed): every blend in four of them now comes in live, their renders unchanged; two still have blends the reader can't match to their drawn steps. Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: the art styles are found before the art is read, so objects in symbols defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes it) come in as themselves with their style instead of their drawn look. Checked on two users' EPS files (used locally, not committed): one's Symbols panel gains live Feather and Round Corners, its drawn-look count drops from 5 to 2, and both render unchanged; own-fixture test in `import::ai::tests`; scores and hours unchanged | | 2026-11-06 | minor | Legacy `.ai` (version 7) export: the clipping path around a flattened transparent image followed 2 pt cells, a staircase that was visibly jagged at the image's resolution. It now follows the image's own pixels (in blocks only past 4 million pixels), grown by one pixel so the whole anti-aliased edge is inside, and runs through the middle of each pixel edge with Douglas–Peucker simplification, so slopes and curves are sloped lines instead of steps (#5264). Version 7 images have no alpha channel (§8.1 of the published format specification), so the image itself is still opaque. Test `legacy_ai::a_flattened_images_clip_follows_its_edge_smoothly` (crate `vectorcraft-engine`); scores unchanged | | 2026-11-06 | minor | Paste: cells copied from Excel, Google Sheets, LibreOffice Calc or Numbers (tab-separated text, quoted cells too) paste as a group of point type, one object per cell in columns and rows, over 0.5 pt gridlines; other text pastes as before; scores and estimates unchanged (#5296) | +| 2026-11-06 | minor | EPS import: a gradient the page paints as a row of abutting one-colour strips imports as one object filled with a linear gradient through the strips' colours (fewer stops where the colours change evenly), its clip path filled with it when it was the clip's only art. Drawn one by one, the strips left seams where the art below showed through each shared edge (PDF 32000-1 §10.6.4, conflation), a pale tint across strips a pixel or two wide; an Illustrator EPS whose page drew its gradients that way differed from its layers and opened as its page. At least 6 strips side by side, each a small colour step from the last; abutting blocks of distinct colours stay apart. Tests `import::tests::abutting_strips_of_a_gradient_become_one_gradient`, `turned_strips_become_a_gradient_across_them`, `abutting_blocks_of_distinct_colours_stay_apart`; 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 | @@ -340,6 +398,7 @@ A Scatter brush in the editing data whose last 15 numbers start with 1 comes int | 2026-10-23 | minor | SVG: attributes in other applications' XML namespaces (`plotter:pen="2"` with `xmlns:plotter`) are kept on the objects and layers they belong to, saved with them in `.vectorcraft` files and written back on export, each namespace declared once on the root under the file's prefix where it is free. SVG's, XLink's, XML's, Inkscape's and Sodipodi's own aside; at most 64 per object of at most 64 KiB each, with a warning for what is left out (the rest of #522). Scores and hours unchanged | | 2026-10-23 | minor | PDF import: a line's spacing is measured from the file's own glyph widths, so type in a font the file doesn't carry, drawn with an installed font whose advances its widths round off (Helvetica on macOS: 2048 units an em), keeps one tracking value on its letters instead of a run per character (#722 follow-up); where the widths are a little off the font's advances (up to the 1% that still counts as the same font) and the differences add up along a line, each stretch of the line takes their share after every character, so its glyphs stay within a hundredth of an em of the file's in a run per stretch (one on ordinary text), instead of a run per character, untracked type points off, or a line-wide fit tenths of a point off; tests with built-in Inter and embedded Source Sans 3, independent of the fonts installed; scores and hours unchanged | | 2026-10-22 | minor | EPS import: a CR LF ending after `readline` counts as one line ending (PostScript Language Reference §3.8), so counted metadata that follows it is no longer shifted by a byte and the editable vectors import instead of the preview; scores and estimates unchanged. | +| 2026-10-21 | minor | SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts. Scores and estimates unchanged. | | 2026-10-21 | minor | PDF export: flattening for PDF/X-1a and PDF/X-3 in a CMYK document draws the areas it rasterizes as ink amounts (a CMYK image), so a CMYK colour under an image with see-through pixels keeps its numbers, K included, instead of going through RGB and back (#2160); own-fixture test; scores and hours unchanged | | 2026-10-21 | minor | EPS and `.ai` editing data: type with an art style of several fills or strokes, effects on a fill (Offset Path, Transform with copies, Drop Shadow, Gaussian Blur, Bas Relief), pattern and gradient fills, per-fill blend modes and a Warp comes in as type with that live appearance instead of its drawn look (outlines), the style read as a tree of filters; other objects keep the rule for plain styles. Point type whose frame has no matrix (written so by newer files) is read; such stories were left out. Where a styled object's drawn look lay, the page isn't held to look like VectorCraft's drawing of the style. `editingData: "always"` also opens an Illustrator EPS from its editing data. Own-fixture tests; a user's file used locally only; scores and hours unchanged | | 2026-10-18 | minor | PDF import: invisible text (rendering mode 3, which carries a scanned page's recognized text) opens as type with no fill or stroke instead of being dropped without a word; when it can't be type (text opened as outlines, Type 3 fonts) it is left out with a warning. PDF export writes characters with no fill or stroke as invisible text in rendering mode 3, so a scanned PDF opened and saved keeps its searchable text, in PDF/X-1a and PDF/A too (#2675); own-fixture tests; scores and hours unchanged | @@ -350,6 +409,7 @@ A Scatter brush in the editing data whose last 15 numbers start with 1 comes int | 2026-10-16 | minor | EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged. Synthetic EPS/parser/minification regressions; user EPS/SVG/JPG kept local. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | | 2026-10-16 | minor | `.ai` editing data: a rotated embedded image lands where its raster matrix puts it, not turned 180° around its corner; the legacy version 7 writer and its prolog write and draw rotated images with the same matrix (#2269). Scores unchanged | +| 2026-10-16 | minor | SVG: CSS classes on imported elements (`class="hero"`) are kept on the object and written back on export, beside the generated `cls-N` classes (#2414); scores unchanged (estimated) | | 2026-10-14 | minor | Legacy `.ai` of version 7 runs as a PostScript program: its prolog is a procedure set of VectorCraft's own that gives each operator the writer uses its meaning (paths, colours, compound paths, clipping groups, layers that don't print, images; gradients flat, type not set), and the page trailer's `annotatepage` (§3 of the specification) marks nothing; every file the writer's tests write is run through VectorCraft's PostScript reader and must draw what the document it was written from draws (#868) | | 2026-10-14 | minor | SVG import: `:not()` selectors (#1799, #1557), stacked descendant selectors over deep nesting no longer stall (#1834, #1703), linked files within the automation roots again (#1870); DXF export writes the VPORT view direction once (#1789); nesting capped at 32 objects with self-using symbols and patterns opened without the offending instance (#1852); deep and self-referencing PDF and `.ai` data (#1871), decompression bombs (#1873), self-drawing brushes (#1872), hostile EPS, Affinity and native data (#1863) and huge ids (#1862) handled; a failed save is reported in a dialog and the old file is kept (#1865, #1685, #1693, #1694); type inside symbols gets its layout bounds back on opening a native file (#1833, #1738). Scores unchanged (estimated) | | 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. | diff --git a/docs/gaps.md b/docs/gaps.md index 85ecd385a..71bcdbdfd 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,9 +1,10 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence +**Feature evidence last updated:** 2026-11-02. | 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. @@ -15,6 +16,8 @@ Group Selection keeps an already selected component selected during a drag; a co Creating a gradient from solid or empty paint uses the document's color model for its default stops, including drawing its vector with the Gradient tool. Explicit `keepModel: true` retains RGB defaults, and existing gradients keep their own colors. Scores and estimates are unchanged. +Window → Help Bar toggles the bottom tool-help strip independently of the status bar. Its visibility is saved with workspaces and UI preferences; Presentation Mode hides both bars. Scores and estimates are unchanged. + The closed font-family fields in Character, Properties and Control now accept the mouse wheel for selected text (#5253). Each effective notch applies one family and adds one undo step; the list follows saved font filters, ignores search text, and @@ -37,6 +40,11 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | +| 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. | +| 2026-11-06 | minor | G9: EPS import reads gradients a page paints as abutting strips as one linear gradient each (no seams); an Illustrator EPS whose page drew them that way keeps its layers. Estimate unchanged. | +| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. | +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-05 | minor | G15: long open strokes with an undashed width profile are outlined near the view while keeping profile positions on the full path; a 200,000-segment outline took 59.95 ms before and 8.44 ms after in local median runs. Closed and dashed profile strokes remain whole; estimate unchanged. | | 2026-11-05 | minor | G15: Direct Selection reuses one document hit result when choosing the cursor over a path; a 200,000-anchor path's segment cursor took 58.15 ms before and 29.33 ms after in local median runs. Cursor behavior is unchanged; estimate unchanged. | @@ -49,6 +57,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-05 | minor | G12: Hyphenation › Default Language sets the language of new type unless Document Setup chose one, stored as a tag; each run hyphenates in its own language, English only so far, so 2 Preferences options stay unused (#394) | | 2026-11-05 | minor | Compound Shapes add-on (beyond Illustrator): Object › Plug-ins › Compound Shapes lists Make, Release and Expand Compound Shape, and holds a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons make a compound shape on a click and give the permanent result on Alt-click (the default is the reverse). Selected members of a compound shape always take the clicked mode. The setting is `window.pathfinder.compoundShapes`; no existing behavior changes while it is off. Scores and estimates unchanged. | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | +| 2026-11-03 | minor | Help Bar has its own Window menu toggle and workspace visibility setting. Scores and estimates unchanged. | | 2026-11-02 | minor | Font-family fields step selected text with the mouse wheel (#5253); no scored gap or estimate reassessed. | | 2026-10-24 | minor | G12: General › Corner Radius is the Rounded Rectangle tool's radius, a change while the tool is active reaches it, and the radius ↑/↓ set while drawing (or a Tool Options value) becomes the preference, within 0–1296 pt (#394, `crates/engine/src/tests/constrain_tools.rs`); no entry closed, estimate unchanged. | | 2026-10-22 | minor | Reconcile upstream documentation after review F-3; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | @@ -106,6 +115,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G1 Interaction fidelity +- **Panel collapse controls (#5925, in part):** The small » in a floating tab-group header moves that group into the side icon rail, reusing the existing rail or creating one. An opened rail panel has the same control to fold its flyout back into the rail, including after a tab switch. Both controls preserve the tab group and its menu; other floating groups and full-width docked headers stay as they were. Pointer regressions in `crates/ui-egui/src/panel_docking/tests.rs` cover grouped tabs, reopening, separate groups, a missing rail, the menu and flyout closure after switching tabs; `native_tests.rs` covers the native-window click and removal of the empty palette. Scores and estimates unchanged. + - **Done:** Distance Guides ([public page](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-distance-guides-for-accurate-placement.html)): with Smart Guides on and something selected, the Selection tool with Alt/Option held over another object shows the horizontal and vertical distances between them, and over an empty part of an artboard the selection's distances from its four edges, labelled in the General unit; Preferences › Smart Guides › Invoke Distance Guides turns them off. Hover only, as documented: not while moving or nudging. No entry closed; estimates unchanged. - **Done (part of #4028):** Recolor Artwork › Assign: double-clicking a row's new color opens the Color Picker, as the [public page](https://helpx.adobe.com/illustrator/desktop/manage-colors/modify-colors/assign-new-colors-to-selected-artwork.html) describes. OK sets the row's new color in the model it was picked in, so a CMYK mix is no longer a hex value converted back; Cancel leaves the row. A Lab new color is kept, but can't be typed yet: the picker's Lab fields work in RGB, as for fill and stroke. **Missing:** Lab entry in the picker, swatch libraries in the picker and the row's right-click menu (Pick from Swatches, Limit to Library). @@ -184,7 +195,9 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G9 Illustrator `.ai` and EPS files from real users -- **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out (the distance from the path under Align to Path comes from VectorCraft's font metrics, and the fit under Gravity does not reproduce the file's layout); EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged. +- **Done (2026-11-09):** the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. This narrows G9. +- **Done (2026-11-06):** an EPS page that paints its gradients as rows of abutting one-colour strips imports them as linear gradients, so the seams between strips no longer show and an Illustrator EPS drawn that way keeps its layers instead of opening as its page. This narrows G9; scores and estimates unchanged. +- **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out (the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box, and the fit under Gravity does not reproduce the file's layout); EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged. - **Save-back update (2026-10-26):** ordinary EPS Save retains page margins and the whole editable document; source-loss confirmation remains. Import fidelity gaps and estimates below are unchanged. @@ -298,6 +311,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G11 Correctness bugs in core tools +- **Done, deletion only (#3380):** deleting anchors or segments that disconnect an ordinary contour creates independent selected path objects, preserving appearance, baked transforms, sibling order and one-step Undo/Redo. Existing multi-subpath paths and compound members stay intact. A second Delete removes all selected survivors. Bounds-fitted gradients keep their original placement on the new objects; already placed gradients stay unchanged. Segment endpoint highlighting remains open; broader G11 scope, scores and estimates are unchanged. Evidence: `crates/engine/tests/it/delete_path_pieces.rs` and the Delete/Backspace frame regression in `crates/ui-egui/src/tests/removeanchors.rs`. + - **Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views:** `live_trace_result_bounds_exclude_the_retained_bitmap_at_any_angle` checks every TraceView, geometric and visual bounds and rotated result bounds. `live_trace_selection_highlight_excludes_the_retained_source_rectangle` checks result highlights and the existing ordinary-group policy. Shared editing-child traversal also preserves Shaper handling. Broader scope and estimates unchanged. - **Implemented, Windows native canvas and regression checks passed in PR #2643 (#2544):** Anchor Point edits selected blend spine points and handles with path-compatible selection/stacking, handle visibility and 45-degree constraints; #2425 perpendicular segment reshaping is preserved. Broader G11 scope and estimates unchanged. @@ -427,7 +442,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall own pixels until a document takes them in; estimate unchanged. - **Missing:** CJK vertical composition (mojikumi sets and dialog, kinsoku settings #633, manual tate-chu-yoko), Middle Eastern - features beyond bidi, digit types, Character Direction and Kashidas (Middle Eastern composers, a split caret), spell check with an open dictionary, + features beyond bidi, digit types, Character Direction and Kashidas (Middle Eastern composers, a split caret), spell check (its engine is done, #4936: `text.spelling.check` and + `text.spelling.suggest` on craft-proof; the Check Spelling dialog, Auto Spell Check and the custom dictionary remain), Touch Type, Retype, Snap to Glyph; optional preservation of font sizes during group scaling (#1034 enhancement). Itemized in [type-parity.md](type-parity.md). - **Impact:** professional typography and Japanese/Chinese publishing. @@ -623,9 +639,9 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall as a `.vectorcraft` library into the user library folder (listed under User Defined) or a file (`brush.libraries`, `brush.addFromLibrary`, `brush.saveLibrary`, `brush.loadLibrary`). - **Missing:** the Art brush's Overlap; more built-in brush, symbol and graphic-style libraries (original, - generated in code, never Adobe's); dynamic symbols' on-canvas editing (Direct Selection of an object inside a - dynamic instance, with the Fill/Stroke controls and Appearance panel then setting its change) and their `.ai` - read and write; Global Edit of groups holding type or + generated in code, never Adobe's); dynamic symbols' on-canvas editing beyond a solid fill and stroke on a path, + compound path or type object inside a dynamic instance (Direct Selection selects it, and the Fill/Stroke controls set + its change) and their `.ai` read and write; Global Edit of groups holding type or images, rotated copies as matches, Shift-click to leave a match out and highlighting matches before the start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). - **Estimate:** 14–24 h. @@ -635,8 +651,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done:** an initial live Revolve in the `three-d` crate (#846, from the contributor who offered in #605), with automatic smooth vector shading, default-on circular caps and spike-free continuous rims; Extrude & Bevel, Inflate and Rotate with Position presets ([three-d.md](three-d.md), #1407). Colored Inflate has automatic smooth vector lighting from shared membrane normals. White-light Inflate keeps its existing appearance. All four take one to ten colored directional lights, with neutral ambient applied once, a light list and independent previewing controls. Standard, Diffuse, Top Left and Right select original rigs, shared with commands; manual settings show Custom. - **Missing:** materials (base, Substance, graphics), shadows, - ray-traced rendering, angular cut caps, exact visibility for intersecting and concave surfaces, 3D Classic, - Turntable, 3D export (OBJ, USDA, glTF). + ray-traced rendering, angular cut caps, exact visibility for intersecting and concave surfaces, the rest of 3D + Classic (Revolve, Rotate, Map Art), Turntable, textures, map art and shading in 3D export. - **Impact:** specialist; Materials is the one menu stub left. - **Estimate:** 25–45 h; plan in `plan/` before coding. @@ -742,9 +758,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.<id>`, 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 @@ -820,6 +841,9 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-09 | minor | G9: EPS import: the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. | +| 2026-11-08 | minor | G4 type: the spell check engine is done (`text.spelling.check`, `text.spelling.suggest` on craft-proof, #4936); the Check Spelling dialog, Auto Spell Check and the custom dictionary remain; scores and estimates unchanged. | +| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. | | 2026-11-07 | minor | Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates 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 | | 2026-11-05 | minor | G15: nearby hits on plain long strokes and outlines check their candidate segments directly; a 200,000-anchor middle hit took about 11–13 ms before and 1.0–1.4 ms after in paired runs. An inside-bounds miss fell from 18.7 ms to 1.6 ms. Estimate unchanged; idle budget run still open. | @@ -902,15 +926,6 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-10-22 | minor | Affinity import: master pages, tables, paragraph formats, text scale, shadows, glows, outlines, overlays, underlines, strikethrough, CMYK pixel layers through the document's profile and editable embedded documents, text placed as Affinity places it (AF.5, from storytold/designcraft#291). No entry closed, estimates unchanged. Hostile files stay bounded: one budget for the whole read (layers, pixels, characters, table cells, embedded documents included), a work budget for overlays, capped master page copies, objects past `MAX_COORD` left out; the import fuzzer and limit tests cover tables, paragraphs, effects, overlays, master pages, CMYK pixels and embedded documents. | | 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. | | 2026-10-21 | minor | G17: filter plug-ins keep data with the document (`documentData`), as its foreign data, so the native file needs no section of its own; engine and unit regressions. No entry closed, estimates unchanged. | -| 2026-10-22 | minor | Artboard Options honors Scale Artwork with Artboard for preset, orientation and typed-size changes; both Fits keep artwork stationary; scores and estimates unchanged. | -| 2026-10-22 | minor | G8: stacked geometry effects make at most 2 million anchors of a path and about 8 million lines to draw: Roughen, Zig Zag, Free Distort, Twist, the warps and Transform build within it, and an effect that would still pass it is left out (#3578). No entry closed, estimates unchanged. | -| 2026-10-22 | minor | G13: gradient Method (30.0), Classic or Perceptual, in the Gradient panel, on the canvas, in raster exports and as images in SVG and PDF exports (with Dither, a Perceptual fill takes its noise); G13 estimate 14–24 h → 13–23 h. | -| 2026-10-22 | minor | G8: SVG images in SVG images share the depth cap (any data URL type usvg reads as SVG) and SVGZ images one 512 MB unpacking cap; past either, an image is left out with a warning (#3562, #3563). No entry closed, estimates unchanged. | -| 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. | -| 2026-10-22 | minor | G19: PDF export asks viewers to display the document title only when File Info has a Document Title or an explicit export title is supplied; untitled documents keep the PDF file name in the viewer title bar (#3646); scores and estimates unchanged. | -| 2026-10-22 | minor | G4: colour emoji fonts with bitmap glyphs (`sbix`: Apple Color Emoji; `CBDT`: Noto Color Emoji) draw on the canvas and export: PDF Type 3 glyphs or images, SVG images with outlined text, EPS and legacy `.ai` images, Create Outlines images; COLR and OpenType SVG fonts remain; estimate unchanged. | -| 2026-10-22 | minor | G13: gradient Dither (30.0) in the Gradient panel, on the canvas, in raster exports and as images in SVG and PDF exports; G13 estimate 15–25 h → 14–24 h. | -| 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h). | | 2026-10-21 | minor | Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations. Scores and estimates unchanged. | | 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. | | 2026-10-21 | minor | Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog. Scores and estimates unchanged. | diff --git a/docs/image-trace-adjustments.md b/docs/image-trace-adjustments.md index 92e5f42ac..77086c503 100644 --- a/docs/image-trace-adjustments.md +++ b/docs/image-trace-adjustments.md @@ -32,6 +32,20 @@ white beside a cream), at most six; scattered specks of a colour add none. Adobe its own palettes are chosen; this is VectorCraft's own method (see [image-trace-quality.md](image-trace-quality.md#auto-color-keeps-rare-colours)). +**Keep Colors.** `"keepColors": ["#rrggbb", …]` in the params of `imageTrace.make` or +`imageTrace.makeAndExpand` traces only the palette colors within about 30 levels of a listed color, +the distance Ignore Color matches by, and leaves the other colors out. With Method Abutting, the paths +of the kept colors are the same as in a trace of every color. With Method Overlapping, a kept color's +paths reach under the kept colors above it in the stack and not under the colors left out, as with +Ignore Color. An empty list traces every color, and a value that is not a six-digit hex color is +refused. With Ignore Color as well, a kept color that Ignore Color matches is still left out. Flat +Logo does not use `keepColors`: it traces the colors named in `logoColors`, or the colors it finds. +The reply's `warnings` name each listed color that matches none of the colors the trace found. When +nothing is traced because no listed color matches, because Ignore Color leaves out every area of the +ones that match, or because the ones that match produce no paths, the first warning says so, and the +status bar shows it. In Flat Logo mode the +warning says that `keepColors` is not used. + **Document Library color groups.** With Palette › Document Library and Library › Document Swatches, the panel's **Colors** menu traces with every document swatch (**All**) or with the colors of one color group from the Swatches panel, as the public @@ -51,6 +65,13 @@ traced fills and strokes take the library color without a link to its swatch. Th spot and tint swatches. Tints trace at the color shown by the tint swatch. Editing a swatch later does not recolor the trace. Colors from other swatch libraries are also unlinked. +**Ignore Color, background only.** Ignore Color leaves out every area of the chosen color. With +`"ignoreBackground": true` beside `"ignoreWhite": true` in the params of `imageTrace.make` or +`imageTrace.makeAndExpand`, it leaves out only the areas of that color that reach the image's edge, +joined through pixels above, below or to either side, and traces the areas of it that other colors +enclose: black line work inside a panel stays when the black around the panel is left out. Flat Logo +leaves out every white area whatever `ignoreBackground` is. + **Document Library** traces with the library's colors as they are. Each pixel takes the library color nearest to it in lightness and hue, with a difference in hue or chroma counted three times as much as one in lightness, so a gray or a muted brown takes a black, white or other neutral diff --git a/docs/mcp.md b/docs/mcp.md index 9220b1c62..3221f299f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -106,7 +106,7 @@ the suite) keep the agent to the folders you give it: - Every file a command reads must lie inside a **read root**: `open_file`, `file.place`, relinking and updating links, the images an SVG links to, swatch library, graphic style library, symbol library, brush library and colour-profile loads, plug-ins, - `file.newFromTemplate`, preset imports, shortcut sets (`shortcutSet.read`), the folders `text.findFontFiles` and `links.findFiles` search, the + `file.newFromTemplate`, preset imports, shortcut sets (`shortcutSet.read`), action sets (`actionSet.read`), the folders `text.findFontFiles` and `links.findFiles` search, the scripts `script.check` reads and the files they include. - Every file a command writes must lie inside a **write root**: `save_file`, `export`, the save and export commands, Export for Screens, Package, Export for Web, print to file, `screenshot {path}`, @@ -269,6 +269,22 @@ chain (layer first) and `limit` caps `matches` (default 100; `0` only counts); w `selected`, the number of objects selected. `document.json` stays whole: it is the fidelity path, not the way to look around. +`run_command document.compare {path, artboard?, background?, threshold?, diffPath?}` measures how far an +artboard is from an image file (PNG, JPEG, WebP, TIFF or BMP). It renders the +artboard (default 0) at the image's size, as `export` draws a PNG: the scale is the image's width in pixels over +the artboard's width in points, and an image whose height differs from the artboard's height at that scale by +more than one pixel is an error that names both sizes. The render and the image are both flattened on +`background` (`"#rrggbb"`, default `"#ffffff"`). The reply is `{width, height, rmse, over, total, threshold}`: +`rmse` is the root mean square of the R, G and B differences on a 0 to 1 scale, rounded to 6 decimals, and +`over` counts the pixels with a channel that differs by more than `threshold` (0 to 1, default 0.08) of the +total `total`. `diffPath` writes a PNG of each pixel's absolute R, G and B differences (black where the two +match) and adds `diffPath` to the reply. As in a PNG export, linked images and placed documents are drawn +from their files rather than from the previews saved for them. The reply's `warnings` are a PNG export's, in +the same order: type on the artboard that doesn't fit its frame, an embedded image that couldn't be decoded (it +is missing from the render), type in a font or style that isn't available, and a linked file that can't be read +(its preview is drawn). `path` is read through the automation read root, and `diffPath` is written through the +write root. The command does not change the document, and it is not available in the web app. + ### Completions `completion/complete` answers for a prompt argument (`ref/prompt`) or a template variable @@ -387,7 +403,10 @@ counterparts, `image.info`, `links.info`, `links.goTo`, `links.unembed`, `shaper `transparency.editOpacityMask`, `transparency.viewOpacityMask`, `symbol.edit`, the `object.blend.spine.*` commands and `path.curvature`) refuse an `id` that is not a non-negative integer (`"abc"`, `"5"`, `true`) with an error and change nothing; an omitted or null `id` keeps the default. A command that leaves out an object selected inside a -selected group also leaves it out when `ids` list it together with a group that holds it. +selected group also leaves it out when `ids` list it together with a group that holds it. Every command that takes +`ids` (or `slices`, for the slice commands) refuses a list with an entry that is not a non-negative integer, or a value +that is not a list, and changes nothing; it does not act on the other entries. The error for an entry names the key, +the entry's index (from 0) and its value: "`ids` entry 1 is true, not a non-negative integer object id". Paint values (`fill`, `stroke`) accept `"#rrggbb"`, `"none"`, `[r,g,b]` (0..1), `{"c","m","y","k"}`, `{"gray"}`, or a full `paint.setFill` params object (`{"gradient": …}`, `{"swatch": "name"}`). @@ -432,8 +451,8 @@ opens the picker through `run_command` with the remote backend. | `inspect_document` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | Artboards (with `color`, `#rrggbb`, for one with a background color), layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. | | `inspect_ui` | `{}` | UI state. Remote mode only. | | `select_tool` | `{tool}` | `selection`, `directSelection`, `pen`, `rectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, … | -| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default, and a `backquote` that is not a boolean is an error. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. | -| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. Remote, the events wait until the keys and other input sent before them (`press_key`, `type_text`) have reached the app, so a key pressed between two calls of one drag applies before the release. When that input is dropped because the document, a dialog or the focus changed first, the call fails and its events are not sent. | +| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default, and a `backquote` that is not a boolean is an error. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. The release of a Shape Builder click or drag over such a selection replies once the merge is applied, headless and in the desktop app, which computes the merge off the UI thread. In the desktop app, a gesture sent before then is made after the merge, and its reply comes once it is made; a command that can edit, select, save or redraw the documents (a preference, the proof and separations views), sent before then, waits for the merge and runs after it. | +| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. Remote, the events wait until the keys and other input sent before them (`press_key`, `type_text`) have reached the app, so a key pressed between two calls of one drag applies before the release. When that input is dropped because the document, a dialog or the focus changed first, the call fails and its events are not sent. The release of a Shape Builder click or drag over such a selection replies once the merge is applied, headless and in the desktop app, which computes the merge off the UI thread. In the desktop app, a gesture sent before then is made after the merge, and its reply comes once it is made; a command that can edit, select, save or redraw the documents (a preference, the proof and separations views), sent before then, waits for the merge and runs after it. | | `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. The options are below. | | `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. `line`: `x1,y1,x2,y2`. | | `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. Returns `{applied, objects}`: `objects` is the number of objects painted, as the `objects` of the `paint.setFill`, `paint.setStroke` and `stroke.set` replies (the largest of them): a selected group counts the objects inside it. When it is 0, a `note` says that only the paint for new art changed. | @@ -443,10 +462,10 @@ 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. | -| `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. | +| `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`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (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). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin.<id>"` 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. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). 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. | +| `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`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `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. | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | | `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). In remote desktop mode, `run_command` and `invoke_menu` can both call `pathfinder.repeat` with `{}`. Successful panel operations, direct `object.pathfinder.*` commands, and `command.batch` steps (including Actions panel playback) set its history. Repeat applies it to the current selection using current Pathfinder Options. Trap, compound-shape edits, live effects and `action.batch` do not set history. With no history it returns "no Pathfinder operation applied yet". | | `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. | @@ -465,6 +484,12 @@ for horizontal writing. This is the same option as `text.create` / `text.createI on the OpenType `vert` feature, so a font's vertical glyph alternates are used; it does not change the engine's CJK composition. +`type.orientation.vertical` and `type.orientation.horizontal` (Type › Type Orientation) switch existing type objects +(the selection, or `ids`) and reply `{vertical, ids}`. The writing direction is a setting of the whole thread: the +commands set every frame of a selected frame's thread, and `ids` lists them all. Threading frames gives the thread its +first frame's direction, frames taken out of a thread keep theirs, and a file whose frames of one thread differ opens +with the first frame's direction in all of them. + For SVG/SVGZ, `export`'s top-level `outlineText: true` writes text as paths; DXF takes it too (its type is outlined anyway unless `preserve: editability`). PDF instead uses `options: {"advanced": {"outlineText": true}}`; its default is false (live text). @@ -512,7 +537,8 @@ While a spiral is being dragged out with the Spiral tool, a `drag` event with `m spiral's inner end at the distance from the center it had on the first modified `drag` event, and the outer end follows the pointer. With `mods.alt` alone, dragging outward adds whole winds of four segments and dragging inward removes them. The fitted segment count rounds to the nearest whole wind from the count on the first Alt-only `drag`, within 2…1000. -With `mods.cmd`, decay changes to fit within 5…99.99 %; it takes precedence when both modifiers are held. Switching +Decay is the percentage retained after each full wind of four segments. With `mods.cmd`, decay changes to fit within +5…99.99 %; it takes precedence when both modifiers are held. Switching back to Alt uses the current count as its new base while keeping the held inner end. The spiral drawn on release uses the new values, and they stay as the Spiral tool's `segments` and `decay` options for the next spiral: @@ -542,18 +568,30 @@ stroke instead of the whole object: `run_command effect.apply {"effect": "path.o On a path directly inside a graph series, `appearance.setItem`, `appearance.addFill`, `appearance.addStroke`, `appearance.removeItem`, `appearance.moveItem`, `appearance.duplicateItem`, `appearance.clear`, -`appearance.reduceToBasic`, `appearance.showAllHidden`, `appearance.transfer` and `appearance.copyFrom` store the paint, -weight and opacity of the topmost visible fill and stroke on the graph. When the graph is drawn again, columns, bars, -wedges, areas, markers and legend swatches take the stored fill and stroke. A thin line takes the stored stroke and has -no fill. A Filled Lines band takes the stored fill, or the stored stroke when no fill is stored and that stroke has -paint, and has a 0.25 pt black outline, so these commands do not store a stroke paint or weight for a series that -draws a band. They still store a stroke opacity set on a band, and the band's outline takes it. Graph -designs keep their own paint. A row that had no paint before the command and has none after it does not change what the -graph stores. `paint.setFill`, `paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other -store the topmost row of the kind even when it is hidden, and store a stroke set on a band. -`stroke.set`, `paint.editGradient`, `paint.swap`, `paint.default`, graphic styles and the recolor commands do not store a -series' paint. An `appearance.*` command given a series group's id edits the group's own rows, and the graph does not -store them. +`appearance.reduceToBasic`, `appearance.showAllHidden`, `appearance.transfer`, `appearance.copyFrom`, `paint.default`, +`graphicStyle.apply` and `graphicStyle.redefine` (on the marks still linked to the style) store the paint, weight, +opacity and blend mode of the topmost visible fill and stroke on the graph. `graphicStyle.apply` with `add` stores only +the kinds of row that are visible in the style. `paint.swap` stores the swapped paints and the stroke weight, keeps the +stored opacity and blend mode, and does not store a hidden topmost row that it swaps. `transparency.set` and +`object.setProps` with an opacity or blend mode for the object, `appearance.clear`, `appearance.transfer`, +`appearance.copyFrom` with Transparency picked up, `graphicStyle.apply` without `add` and `graphicStyle.redefine` store +the object's opacity and blend mode on the graph, and the other commands above store them when they change them on a +mark. The graph stores an opacity and blend mode as a pair, taken from the mark the command edited: a command that sets +only the opacity of one mark's object or row also stores that mark's blend mode in place of the one another mark +stored. Every path directly in the series takes the stored values when the graph is drawn again. The graph does not +store the rest of a graphic style's look (more fill and stroke rows, effects, and the link to the style), and a mark +loses it when the graph is drawn again. When the graph is drawn again, columns, bars, wedges, areas, markers and legend +swatches take the stored fill and stroke. A thin line takes the stored stroke and has no fill. A Filled Lines band takes +the stored fill, or the stored stroke when no fill is stored and that stroke has paint, and has a 0.25 pt black +outline, so these commands do not store a stroke paint or weight for a series that draws a band. Except for +`paint.swap`, they still store a stroke opacity and blend mode set on a band, and the band's outline takes them. Graph +designs keep their own paint. A row that had no +paint before the command and has none after it does not change what the graph stores. `paint.setFill`, +`paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other store the topmost row of the kind +even when it is hidden, and store a stroke set on a band. `stroke.set`, `paint.editGradient` and the recolor commands do +not store a series' paint. An `appearance.*` command or `graphicStyle.apply` given a series group's id, and +`graphicStyle.redefine` on a series group linked to the style, edit the group's own rows, and the graph does not store +them. `effect.apply {"effect": "path.outlineObject"}` (Effect › Path › Outline Object) takes no parameters. Type that carries it is drawn, exported and expanded as its glyph outlines and stays editable type, and the effects after it reshape @@ -568,7 +606,8 @@ global or spot swatch follow its colour and name, as one undo step, in the art, designs, pattern tiles, graphic styles and the art of brushes); `swatch.delete {names, unlink?}` deletes swatches and colour groups in one undo step (art using a deleted global swatch keeps its colour, unlinked). `swatch.new {color?, mode?, spot?, global?, group?}` saves a colour (into a colour group with `group`), gradient -or pattern; `swatch.newGroup {fromArtwork: true, toGlobal?, includeTints?}` makes a colour group of the selected +or pattern (a gradient swatch holds no link to the swatch its gradient came from, and a pattern swatch holds the +pattern untransformed); `swatch.newGroup {fromArtwork: true, toGlobal?, includeTints?}` makes a colour group of the selected art's colours (by default as global swatches the art links to). `swatch.move {names, to?, group?}` reorders swatches or moves them into or out of a colour group (as dragging them in the Swatches panel does); give colour group names instead to reorder the groups. Dropping a swatch on art @@ -663,8 +702,8 @@ effect colors that new art takes; Undo restores their original values and Redo r colors of 3D effects stay RGB. Gray colors stay Gray and print on the black plate only. RGB grays are colors like any -other: to CMYK they separate through the profile into four-colour greys and a rich black, as Illustrator converts -them. `grays: "black"` puts RGB greys with R = G = B on the black plate instead, K = their grey value (the inks Edit Colors › +other: to CMYK they separate through the profile into light grays in cyan, magenta and yellow only, darker +four-color grays and a rich black. `grays: "black"` puts RGB greys with R = G = B on the black plate instead, K = their grey value (the inks Edit Colors › Convert to Grayscale before the switch would give); other colors convert as usual. The reply's `changed` counts the colors that changed in the art, symbols, graph designs and pattern tiles; it does not count the colors of graphic styles and brushes. @@ -770,7 +809,8 @@ A `security` password encrypts the file (RC4 40-bit at PDF 1.3, RC4 128-bit at 1 1.7 and 2.0) with its permissions; either password opens it (`document.open {password}`), and a password with PDF/A or PDF/X is refused. Pattern fills and strokes are written as their tiles clipped to the area they paint (a stroke's outline, with its dashes, caps, profile, arrowheads and alignment), and freeform gradients as an image of their colour field at the -document's raster effects resolution, clipped the same way. +document's raster effects resolution, clipped the same way (a CMYK image when every point is CMYK or, in a CMYK +document, every point is Gray; the Output settings convert it as they convert any CMYK image). `createLayers` writes each layer and sublayer (template layers are left out) as a PDF layer, an optional content group named as the layer and listed under its parent layer's: hidden layers are off (their art is written, for the reader to show), non-printing ones have `/PrintState /OFF` and locked ones are locked. It needs PDF 1.5 or later (at 1.4 it @@ -836,6 +876,12 @@ an EPS as its page, a `.ai` file from its PDF content (a `.ai` saved without PDF {"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/label.eps","editingData":false}}} ``` +Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, gradients, patterns, graphic styles, +swatches, brushes, symbols, variables and data sets, and the text values of data sets) are read as Windows-1252 unless +`document.open` takes `legacyNameEncoding`: `macintosh`, `windows-949` (also `euc-kr`), `shift_jis`, `gbk` or `big5`. An +unknown label is refused. The `warnings` of the result name the first three such names as read and count the rest. +`vectorcraft-cli convert --legacy-name-encoding ENC` passes it on. + Opening a PDF (or `.ai`) imports every page as an artboard and layer; `document.open` takes `pages` ("2-3, 5", 1-based), `cropTo` (`bounding` (the art's bounds), `art`, `crop` (default), `trim`, `bleed`, `media`: the box each artboard gets; with `trim` the bleed box round it becomes the document's bleed, and a PDF-compatible `.ai` or a PDF saved with its editing data opens on its trim boxes for @@ -984,7 +1030,10 @@ line?}`, `setPoint {index?, …}`, `deletePoint {index?}`, `addLine {points}`, ` `selectPoint {index|null}` edit them (`index` defaults to the selected point; each edit is one undo step). The color of a point colored along the stops, of a point `splitLine` adds and of a point `addPoint` adds without `color` is a mix of the colors around it (the two stops, the line's two points, or all the points) in the color model those colors -share, so CMYK stops and points give CMYK points. With the +share, so CMYK stops and points give CMYK points. Points that are all CMYK, Gray, Lab or RGB mix in that model on the +canvas, in the swatch and Fill and Stroke chips, and in the SVG, PDF, EPS and EMF writers; points in different models +mix in display RGB (on the canvas in a CMYK document that shows transparency, as the inks of each point). With +Appearance of Black set to Rich, the canvas mixes points that include pure CMYK black in display RGB. With the Gradient tool on a freeform gradient a click on a point selects it (drag to move it), a click on a line adds a point on it, a click elsewhere on the art adds a point (in Lines mode joined to the selected one), and Delete removes the selected point: @@ -1122,8 +1171,8 @@ refused before the command runs: → invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`? ``` -Only the top level is checked: the keys of an object inside a value (`artboardLayout: {…}`) are the command's to -read. The one exception is an effect's `params`: `effect.apply` (and `appearance.addEffect`) and `effect.setParams` +Only the top level is checked, except for the export and save commands below: the keys of an object inside a value +(`artboardLayout: {…}`) are the command's to read. Another exception is an effect's `params`: `effect.apply` (and `appearance.addEffect`) and `effect.setParams` check them against the effect's own entry in `effect.list`, its `defaults` and the keys its `params` text names. A key the effect doesn't take, or a value of another type than its default (a string for a number, a number for a switch), is refused the same way: @@ -1161,8 +1210,8 @@ A number outside the range the effect documents is still stored as given and cla plug-in effect's parameters are resolved against its manifest instead. `effect.setParams` as a step of `command.batch` is checked when the step runs, against the effects the steps before it added, and the batch then fails with no change. A description with a `…` entry takes more keys than it lists: those of the command it names -(`…settings as file.new`), or any key when it names none, as `document.export` and `document.save` do for the options -of the format they write (one object can carry several formats' options). A command that takes its own reply back +(`…settings as file.new`), or, except for the export and save commands below, any key when it names none (one +object can carry several formats' options). A command that takes its own reply back (`document.setup`, `text.areaOptions`) says so the same way. Saved actions aren't checked this way. When the Actions panel plays an action (Play at any speed, an action's function @@ -1170,6 +1219,42 @@ key, the Batch dialog or `ui.action.play`), a key a command no longer reads, rec and the action still plays. `Session::replay` plays a journal the same way. The command still refuses an effect `color` or `channel` it can't read. +The export commands limit those keys. `document.export`, `document.exportSelection`, `document.serialize`, `app.export` +and `file.export` refuse a key that no format takes, and ignore a key that only another format takes +(`{"format": "pdf", "quality": 80}` writes the PDF). `document.exportPdf`, `document.pdfSettings` and `pdf.preset.save` +refuse a key that PDF doesn't take, `document.exportEps` one that EPS doesn't take and `document.exportDxf` one that DXF +doesn't take. Each of them refuses an unknown key inside an option object it takes (`marks`, `bleed`, `compression`, +`output`, `advanced`, `security`, `flattener`, `svg`), naming its path: + +```json +{"id": "document.export", "params": {"format": "pdf", "marks": {"useDocumentBleed": true}}} +→ invalid parameters for `document.export`: unknown parameter `marks.useDocumentBleed`; `marks` takes: … +``` + +The save commands limit them the same way. `document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate`, +`app.save` and `file.save` refuse a key that no save format takes, given beside the path or in `options`, and an +unknown key inside a PDF option object or the `svg` object, also when the file is not SVG. A save still leaves out a key +that only another save format takes. It warns when that key is given in `options` or is one of `compress`, `version`, +`preview` and `precision`: + +```json +{"id": "document.save", "params": {"path": "a.pdf", "options": {"noSuchOption": true}}} +→ invalid parameters for `document.save`: unknown parameter `options.noSuchOption`; no save format takes it (see file.formatOptions) +``` + +`document.exportForScreens` and `file.exportForScreens` refuse a key the command doesn't take. In each row of `formats` +and in the options `settings` gives a format, they refuse a key that no format takes, as `document.export` does, and an +unknown key inside a PDF, EPS or SVG option object; `settings` takes only the formats the command writes. A row and a +format's settings also take `includeArtboardFill`, `richBlack`, `params` (an exporter plug-in's own) and +`security.passwordRequired` (which remembered settings hold where PDF passwords were left out), and `settings` +takes an exporter plug-in's format. The error names the row (`formats[1].marks.useDocumentBleed`). +`document.exportPresets.save` checks its `settings`, with the rows and settings in it, the same way, and `assets.export` +and `assets.settings.set` check the rows and settings they are given. + +A saved PDF preset or Export for Screens preset that holds a key that is no longer read keeps loading. The Export for +Screens dialog opens remembered settings and presets without such keys, and the Asset Export panel leaves them out of +the settings it sends, so their export is not refused. + An unknown command id is matched the same way as an unknown parameter key: the error names the nearest command id when one is near, ignoring case. A step of `command.batch` or `action.batch` gets the same suggestion in its step error: @@ -1669,6 +1754,21 @@ changes neither option. Behavior reference: [View artwork](https://helpx.adobe.c {"name":"invoke_menu","arguments":{"command":"ui.navigatorPanelOptions","params":{"color":"Light Blue","solidDashes":true}}} ``` +## Tools panel placement + +By default the docked Tools panel sits at the window's left edge, outside any panels docked on the left. +`window.toolsBesideCanvas {on?}` (Window › Toolbars › Keep Tools Beside the Canvas, remote mode, `invoke_menu`) +docks it between those panels and the canvas instead (`on: true`), back at the window's edge (`on: false`), or +toggles it (`on` omitted or null), and answers the new state. Any other value of `on` is an error and changes +nothing. With the setting on, a floating Tools panel docks when it is dropped on the canvas's left edge as well as +on the window's left edge, and `window.panel.dock {panel: "tools"}` docks it beside the canvas. A floating Tools +panel stays floating when the setting changes. The setting is VectorCraft's own layout option, off by default; it is +kept with the workspace, and built-in workspaces and workspaces saved without it keep Tools at the window's edge. + +```json +{"name":"invoke_menu","arguments":{"command":"window.toolsBesideCanvas","params":{"on":true}}} +``` + ## Ruler guides Ruler guides are numbered in the order they were made. `guide.add {vertical, pos, artboard?}` makes one (the x of a @@ -1749,9 +1849,42 @@ already walks Image Trace. Short agent entry points: art for itself only and stays linked. `symbol.instance.setAppearance {id?, path, fill?, stroke?, opacity?, blend?}` names the object by `path`, child indices in paint order from the art's root down through its groups (`[]` is the root; `[1, 0]` is the first object of the art's second member when that member is a group). `fill` and `stroke` take - a colour string or the paint params of `paint.setFill` (`color`, `none`, `swatch`, `tint`, `gradient`), `opacity` is - 0..100 and `blend` a blend mode name; `null` gives one back to the symbol. It is refused for a static symbol and for - a path that names no object. `symbol.instance.reset {ids?}` drops the selected instances' changes (the Properties + a colour string or the paint params of `paint.setFill` (`color`, `none`, `swatch`, `tint`, `gradient`). Paths, + compound paths, type, groups and layers take both. A symbol instance takes a solid color or `none` as its fill, + which stains its art, and no gradient or pattern fill and no stroke. An image takes a fill or stroke only when that + fill or stroke has a Convert to Shape or Outline Object effect. A guide, blend, envelope, Repeat, Intertwine group, + gradient mesh or placed document takes neither. A refused fill or stroke (VectorCraft's own rule) gives an error + that names the key and the object, and nothing of the call is applied; `opacity`, `blend` and `null` are taken for + every object, so a stored change can be taken back. `opacity` is 0..100 and `blend` a blend mode name; `null` gives + one back to the symbol. It is refused for a static symbol and for a path that names no object. On the canvas (VectorCraft's own rule), a Direct Selection click on an instance of a + dynamic symbol selects the path, compound path or type object of its art under the pointer and outlines it + (`symbol.instance.selectPart {id, path}`; Alt or Shift selects the instance whole). While it is selected, + `paint.setFill` and `paint.setStroke` with a solid color or none, `paint.invert` and `paint.complement` on a solid + color, all without `ids`, `id` or `item`, and the Eyedropper's `appearance.copyFrom` with a solid fill and stroke + change that object's fill or stroke for this instance only, each as one undo step. A painted stroke on type without a + weight gets 1 pt. A gradient or pattern for the object is refused with a status message, and so are `paint.invert` and + `paint.complement` on type whose characters differ in color, Eyedropper options that pick up neither color, an + Eyedropper source that is not a path, compound path or type (a symbol instance or a group, for which the paint defaults + stay as they are too), and a click + or `symbol.instance.selectPart` on any other object of the art (a group, image, blend or nested instance among them). + The commands that change the selected objects without `ids` are refused the same way and change nothing, among them + opacity and blending mode, moving and transforming, `path.moveAnchors`, `file.place` with `replace`, Puppet Warp, Delete + and Cut, Group, Hide, Lock, Arrange, Expand, + Recolor Artwork, plug-in filters, effects, the gradient commands, `gradient.selectStop`, the Appearance panel's rows and + `symbol.instance.setAppearance`. A Cmd-drag that lends the Selection or Group Selection tool selects the instance whole + first and moves it. `swatch.unused` and `recolor.colors` count the fills and strokes an instance keeps for objects of its + art. Commands that change nothing in the document, such as `attributes.info`, `css.selection`, `css.export`, + `effect.list` and `object.expand.info`, read the instance. These commands do not check for a selected object and use the instance: + `edit.copy` copies the instance whole; `document.exportSelection`, `document.export` with `selectionOnly` and + selection-mode `document.exportForScreens` export it whole; `artboard.fitToSelection` fits the artboard to the whole instance; + `object.liquify` without `ids` takes the instance as its target and, since it reshapes paths only, leaves it as it is + and lists it in `skipped` when the brush reaches it; `object.isolate` without `id` isolates the instance and clears the + selection; `library.add` without `ids` adds a copy of the whole instance as a `graphic`, and its fill and stroke kinds + read the instance rather than the selected object; `select.save` saves the instance, and `select.recall` then selects it + whole. Direct Selection passes over hidden, locked and template objects of the art, and + `symbol.instance.selectPart` refuses a path to one or to an object inside one, and an instance that is hidden or + locked or sits in a hidden or locked group or layer. Choosing another selection tool selects the instance whole again. + `symbol.instance.reset {ids?}` drops the selected instances' changes (the Properties panel's Reset). Redefining the symbol (`symbol.update`) reshapes every instance and keeps each change whose path the new art still has; `symbol.breakLink` bakes the changes into the expanded art; `symbol.edit` edits the symbol's own art, and each edit there keeps the changes whose path the art still has; making the symbol static drops them, and @@ -1812,7 +1945,8 @@ already walks Image Trace. Short agent entry points: first value at −180° to its second at 180°. A stroke without that input draws the brush's own value (a Scatter brush's value half way between the two). - Variables: `variable.list`, `variable.define {name, kind: text|visibility|image|graph}`, `variable.bind {variable, ids?}`, - `variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeImageDynamic`, `variable.makeGraphDynamic`, + `variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeImageDynamic`, `variable.makeGraphDynamic` + (`variable.bind` and `variable.makeTextDynamic` refuse type that already has another text variable and name it), `variable.unbind`, `variable.rename`, `variable.delete`, `variable.highlight` (image values are file paths, graph values CSV as `graph.setData`). `variable.lock {locked?}` (Lock Variables) locks or unlocks the variables; without `locked` it toggles, and the lock is saved with the document. While the variables are locked, @@ -1887,7 +2021,20 @@ already walks Image Trace. Short agent entry points: Export end the file name of a step with `format` in that `format`, in lower case and without a leading dot, when it is one of the format's extensions (`.svg` and `SVG` give `.svg`, `.JPEG` gives `.jpeg`), and otherwise in the format's first extension (`png8` gives `.png`, `template` gives `.vctemplate`). A step without `format` keeps the - source file's extension (`.vectorcraft` over data sets). + source file's extension (`.vectorcraft` over data sets). Override Export gives a `document.exportForScreens` or + `assets.export` step the `folder` `destinationFolder/<stem>`, in place of the step's own folder or an export + preset's: one folder for each source file, named after the file without its extension, or for each data set, + named as its files are (`datasetFileNaming`). Both names drop trailing periods and spaces (`a..svg` gives `a`). The export names + the files in it as it does outside Batch. When two source files have the same name without the extension + (`a.svg` and `sub/a.svg`, `a.svg` and `a.ai`, or `a.svg` and `a..svg`), the folder destination and the overrides + add `-2`, `-3`… to the name for each later file (compared without case), as for data sets. They also add it when a + later file's folder or file would have the name of a file or folder an earlier one wrote: with the folder + destination, `a.svg` is saved as `a.svg`, so the Export for Screens folder of `a.svg.svg` is `a.svg-2`. When + `destinationFolder` holds source files, only a file's own output takes its name, and no export folder takes the + name of a subfolder that holds source files: with `a..svg` and `a.svg` saved in place, `a..svg` is saved as + `a-2.svg` and `a.svg` over itself. A file's artboard files count as its files, as for data sets: with + `a.vectorcraft` and `a-Artboard-1.vectorcraft` saved in place with `separateArtboards`, `a.vectorcraft` is saved + as `a-2`. - Action set files: `ui.actionSet.save {set?: index, id?, path?}` writes the Actions panel's set at `set` (0 is the top set; by default the current set) with its actions to an action set file at `path`, else a picked file named after the set (the web downloads it), and returns `{path}`. `id` holds the panel's own set id, which the panel menu @@ -1905,7 +2052,8 @@ already walks Image Trace. Short agent entry points: instead of playing the excluded steps or failing at a stop; other files use version 1. A file with another `format` or a `version` other than 1, 2 or 3, a stop whose message is not text or whose `allowContinue` is not true or false, a file larger than 8 MiB, one with more than 1,000 actions and one with an action of more than 10,000 steps are refused, and no set is added. The Actions panel menu's Save Actions… and Load Actions… run these; Load Actions… is - disabled where there is no open dialog (the web). + disabled where there is no open dialog (the web). `ui.actionSet.load` also reads the action sets other apps save as + text (`.aia`); see "Action sets from other apps". - Actions: `ui.action.rename {set, action, name}` renames the Actions panel's action at `action` in set `set` (0 is the top set) to `name` with spaces trimmed from both ends, keeps its steps, function key and color, and returns `{name}`. An index past the last set or action, or an empty name, is an error, and then nothing is renamed. @@ -1998,6 +2146,19 @@ already walks Image Trace. Short agent entry points: - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. A key inside `params` that is not one of the trace settings, such as `colours`, is an error that names it and the nearest setting (`colors`), as for a command's own keys. + With `byColor: true`, `imageTrace.expand` and `imageTrace.makeAndExpand` put each color's paths in a group of their + own, named by the global swatch the color links to (a tint with its percentage, as `Sky 50%`) or as `#rrggbb`; + gradient fills share a group named `Gradients`. The groups stack in the order of the lowest path in each, and each + keeps its paths' order. For either Expand command, a path that would move under a path of another + color whose painted bounds, effects and shadows included, come within one document point of it starts another group + of its color instead of joining an earlier one. Paths whose painted bounds come within one document point of an + intervening color stay in separate groups to preserve their stacking order; at small view or raster-export scales, + regrouped paths farther apart can still share antialiased pixels, so edge pixels may differ. In a Flat Logo trace of a + black ring around a white disc around a black dot, black gets two groups. `layer.releaseToLayers` on the expanded group then + gives each group a sublayer. A trace made with `autoGrouping` is refused. Expanding by color also refuses a trace + with a live Pathfinder effect. + With `keepColors` in `params`, the reply's `warnings` name each listed color that no traced color matches, and say + when nothing was traced. `imageTrace.presets.save {name?, newName?, preset?, enhanced?, params?}` saves tracing settings as a preset kept with the preferences: `params` over `preset`'s (default: the saved preset `name`, else Default), checked as `imageTrace.make` checks them; without `name` it is a new "Tracing Preset N", and `newName` renames a saved preset (the reply's @@ -2074,11 +2235,16 @@ document stays unmodified. `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, inside a locked layer or group, or on a locked artboard, and count it in `skipped`. They rewrite hidden objects and objects inside +on a locked artboard, locked, or inside a locked layer or group, and count it in `skipped`, unless the value is a +visibility value, which shows or hides an object that is locked or inside a locked layer or group but not one on a +locked artboard. 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. A text value bound to any frame of threaded text replaces the whole story, and the text flows through the frames again; `overflow` names the story's last frame when the text does not fit. `dataset.capture`, `dataset.update` and @@ -2108,8 +2274,9 @@ A `dataset.import` error about a value says why it was refused: a visibility val or no, or a graph value longer than 262,144 bytes. `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, -on a locked artboard, 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, or for any variable on a locked artboard, 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. When every bound object is locked, it writes no value and adds no undo step, and `dataset.capture` returns the error "no bound object reports a value (locked objects and embedded images report none)". @@ -2216,8 +2383,9 @@ See [Revolve](revolve.md) for the scope and limitations. `effect.lightingPreset {index: int, preset: "standard" | "diffuse" | "topLeft" | "right", item?, ids?, target?}` replaces the lighting rig and ambient of an existing Revolve, Extrude & Bevel, Inflate or Rotate effect. `item: null` addresses object-level effects; an item index addresses a fill/stroke's -effects. All targets must have a 3D effect at `index`, otherwise nothing changes. The operation -is one undo step and preserves Shading, geometry, source paint and the view. +effects. All targets must have a 3D effect at `index`, otherwise nothing changes. Extrude & Bevel +(Classic) has no lighting presets: a target with it at `index` is an error, nothing changes, and +`effect.setParams` sets its lights. The operation is one undo step and preserves Shading, geometry, source paint and the view. The four original rigs use one white front light at 80% intensity. Standard keeps the existing default; Diffuse uses 100% Softness and 35% Ambient Light, Top Left uses Rotation 135 and Height @@ -2259,6 +2427,33 @@ All three take `rotationX/Y/Z` (degrees, -180..180, default the Off-Axis Front p `perspectiveAngle` and the lighting parameters as Revolve. See [three-d.md](three-d.md) for the Position presets, the scope and what differs from the public documentation. +## Extrude & Bevel (Classic) + +`effect.apply {effect: "threeD.classicExtrude", params: {...}, ids?, item?}` is Effect › 3D and Materials › +3D (Classic) › Extrude & Bevel (Classic)…. It builds the same solid as `threeD.extrude` and draws its faces with the +classic surfaces. Parameters: `depth` (points, 0..2000, default 50), `cap` (true solid, false hollow), `bevel` (`none`, +`classic`, `rounded`, `cove`, `step`, `ogee`), `bevelHeight` (points, 1..100, default 4; the bevel is as wide as it +is tall), `bevelInside` (false adds the bevel around the shape, true carves it into the shape), `rotationX/Y/Z` and +`perspectiveAngle` as above, and the surface: + +- `surface`: `wireframe` (the edges of every face, hidden faces included, with no fill), `none` (the art's own paint, + unlit), `diffuse` (soft light) or `plastic` (soft light plus a highlight; the default). +- `lightRotation`, `lightHeight`, `lightBehind` and `lightIntensity` (0..100%, default 100) place one light, or + `lights` holds one to ten (`intensity`, `rotation`, `height`, `behind`; their light adds up, and `color` and + `softness` are not used). `ambient` (0..100%, default 50) lights every face. +- `highlightIntensity` (0..100%, default 60) and `highlightSize` (0..100%, default 90; 100 is broad) shape the Plastic + highlight. `blendSteps` (whole number, 1..256, default 25) is how many levels the shading is rounded to. +- `shadingColor`: `black` (the default), `custom` (darkens toward `shadingCustom`, a `#rrggbb` color) or `none` (the + paint is kept and only the highlight lightens it). `drawHiddenFaces` also draws the faces turned away from the + viewer, behind the others. + +The defaults are VectorCraft's own. An unknown parameter, a number out of range, a flag that is not true or false, or +an unknown surface or shading color is refused with an error and the document is not changed. + +```json +{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"threeD.classicExtrude","item":null,"params":{"depth":40,"bevel":"rounded","bevelHeight":6,"surface":"diffuse","blendSteps":64}}}} +``` + ## Edit Colors and Recolor Artwork @@ -2273,6 +2468,15 @@ For `edit.colors.*`, `fill: false` leaves colorized image fills unchanged. `reco `edit.colors.saturate` needs `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}}} @@ -2343,7 +2547,10 @@ counts as a value left out except for `recolor.apply`'s `model` and a `map` row' Groups and layers carry fills, strokes and effects of their own, as in the reference app: their fills and strokes paint every member's geometry, and their effects apply to the members as one piece (one combined drop shadow; a -Transform or Warp moves or bends the whole group). Their Contents row (type: Characters) is a slot in the stack: +Transform or Warp moves or bends the whole group). Under a group's Warp, each placed gradient moves and scales from +bounds before the warp to bounds after it: a path's or compound path's gradients use its own bounds, a nested group's +own fills and strokes use the nested group's bounds, and the group's own fills and strokes use the group's bounds; a +stroke uses those bounds inflated by the stroke. Their Contents row (type: Characters) is a slot in the stack: fills and strokes above it paint over the members (characters), those below under them; new items go above it (on an image, below it). `appearance.moveItem {"from": "contents", "to": n}` puts the row above the bottom `n` items, and an item move takes @@ -2472,7 +2679,9 @@ what dragging a symbol out of the library panel onto the canvas does. `symbol.saveLibrary {path?, names?, name?, user?}` writes the document's symbols as a symbol library: a `.vectorcraft` document holding only them and what they use; without `path` it returns `{dataBase64}`, and `user: true` saves into the user library folder (category `user`, User Defined). `symbol.loadLibrary {path? | data? | dataBase64?, name?}` -loads the symbols of any document VectorCraft opens as a library to add from; a file it can't read is an error. +loads the symbols of any document VectorCraft opens as a library to add from (a `.ai` or EPS whose editing data holds +symbols and no layers gives the symbols it defines, in the order of its symbol list); a file it can't read is an +error. ## Brush libraries @@ -3091,6 +3300,63 @@ it. The native format keeps the events and the files. {"name":"run_command","arguments":{"command":"svgInteractivity.addFile","params":{"file":"menu.js"}}} ``` +## SVG filters + +Effect › SVG Filters lists the document's SVG filters below Apply SVG Filter… and Import SVG Filter…; choosing one +applies it to the selection. `svgFilter.list {}` returns them as `filters: [{name, xml}]`. Each is one `<filter>` +element whose `id` is its name, with 1 to 64 primitives and at most 64 KB of XML (`feTurbulence` octaves up to 10, +`feConvolveMatrix` kernels up to 9 cells a side, `feMorphology` radii and `kernelUnitLength` up to 100 and +`stdDeviation` up to 500 user units, elements nested at most 16 deep). It holds only filter primitives, +what they hold, and `desc` and `title`: no scripts, no event attributes, and images only as PNG, JPEG, GIF or WebP +`data:` URLs of at most 4096 × 4096 pixels. A document +holds at most 64. `svgFilter.define {xml, replace?}` adds +a filter after the others, or with `replace` replaces the filter of that name, which keeps its name; a name another +filter has is refused, and the same filter again records no undo step. `svgFilter.delete {name}` removes one, and `svgFilter.import +{path?|svg?}` adds the `<filter>` elements of an SVG file (at most 16 MB, not SVGZ, the first 256 filters) or of SVG text, leaving out the names the +document already has and the filters `svgFilter.define` would refuse (`{added, skipped}`). Each change is one undo +step. `svgFilter.apply {name, ids?|id?, item?}` applies one as the live effect `svg.filter`, as `effect.apply` does, +in one undo step. The effect (`{name, xml, scale, origin}`) names the document's filter: editing the filter changes +what every object that uses it draws, in the edit's one undo step, and an application after an edit takes the edited +filter. Deleting a filter that objects use leaves their effects in place, still named, drawing nothing (their `xml` +empty) until a filter of that name is defined or imported again. Opened from such an effect, Apply SVG Filter shows the +name above the list, not selectable. `effect.apply {effect: "svg.filter", params: {xml}}` takes a filter +directly and names it by its `id`; an `xml` that isn't one such filter is an error. `ui.svgFilterDialog {}` opens Apply +SVG Filter (New, Edit, Delete, Import…), and from the Appearance panel it edits which filter an applied effect runs; +`ui.importSvgFilter {}` picks an SVG file and imports its filters. + +The canvas, raster export, printing and PDF draw the filter with the object as its source graphic, its user space +starting at `origin` (`[x, y]` in points, `[0, 0]` by default) with its units `scale` points (1 by default), its +object bounding box the drawn object's, and at the Document Raster Effects resolution when the view is finer. The +filter runs on coarser pixels when a morphology radius or kernel unit would span more than 32 of them, or a standard +deviation more than 512. An effect whose XML these rules refuse (from a file or a script) is not drawn or exported. The +filter is drawn and written rebuilt from its filter elements and their filter attributes alone, without comments, +text, descriptions or other attributes. An image in a filter is drawn only when its bytes are a PNG, JPEG, GIF or WebP +within the pixel limit. A filter region in user space given in percentages is measured against the object's box (x +and y from its top-left corner), and one in em or ex or left out takes the default region of that box (10% beyond +each side); these are VectorCraft's own rules. Scale Strokes & Effects scales +`scale`. SVG export writes the filter as a `<filter>` element when it is the object's last effect, its only pixel +effect and `scale` is 1 (with a region measured against the object's box, its only raster effect), with the object in a +group that places the filter's user space and such a region written in user units. The filter is written in SVG 1.1 +terms: `feImage`'s reference as `xlink:href`; each `feDropShadow` as the blur, offset, flood, composite and merge that +[Filter Effects](https://www.w3.org/TR/filter-effects-1/#feDropShadowElement) gives as its equivalent; colors as +`#rrggbb` with a flood color's alpha in `flood-opacity`; `image-rendering` as `optimizeSpeed` or `optimizeQuality`; +the inputs `BackgroundImage`, `BackgroundAlpha`, `FillPaint` and `StrokePaint` as `SourceGraphic`, and an input that +names no earlier result as the previous primitive's result; +and an `feComposite` operator SVG 1.1 lacks, or `edgeMode` on a primitive other than `feConvolveMatrix`, left out, as +the canvas draws them. A filter with an `feBlend` mode SVG 1.1 lacks (`overlay` and the others), an `feDropShadow` +whose `flood-color` is `currentColor` or with `primitiveUnits="objectBoundingBox"`, or more than 64 primitives once +its shadows are expanded, has no SVG 1.1 form. Neither does a filter whose written form, with its region and id, is +one SVG import would not read, such as one longer than 64 KB. In that case, otherwise, and with the SVG Tiny 1.2 profile, the object +becomes an image. SVG import keeps a filter that the live effects (Drop Shadow, the +glows, Gaussian Blur, Feather) can't express as an `svg.filter` effect holding the filter's XML, with `scale` and +`origin` from the object's user space, unless that user space is turned, skewed or scaled past 0.001 to 1000 points +to a unit, or the filter is styled by the document's style sheets: such a filter is left out with a warning. The native format keeps the document's filters and the effects. + +```json +{"name":"run_command","arguments":{"command":"svgFilter.define","params":{"xml":"<filter id=\"Soft\"><feGaussianBlur stdDeviation=\"2\"/></filter>"}}} +{"name":"run_command","arguments":{"command":"svgFilter.apply","params":{"name":"Soft","ids":[12]}}} +``` + ## Removing anchor points `path.removeAnchors {}` (Object › Path › Remove Anchor Points) removes the direct-selected anchors (`select.anchors @@ -3344,6 +3610,56 @@ Absolute target, so it leaves objects that already have that size as they are; r {"name":"run_command","arguments":{"command":"object.transformEach","params":{"relative":false,"scaleH":50,"scaleV":50}}} ``` +## Align to Pixel Grid + +`object.setPixelAligned {ids?|id?, on?}` turns the pixel-aligned property of the objects given (default: the selected +objects) on or off (`on` defaults to true), as the Transform panel's Align to Pixel Grid checkbox does (described on +the help page, +<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/pixel-preview-mode-for-web-graphics.html>). +It returns `{ids}`, the objects whose property changed. While the property is on: + +- The object's horizontal and vertical straight segments sit on whole points, or on half points when a stroke centered + on the path has an odd weight, so that the stroke's edges fall on the pixel grid. A stroke that is hidden or has no + paint does not count. Its stroke weights are whole points, at least 1. +- Every move, scale, rotation, reflection, shear, Transform Each, Align or Distribute move (of objects or of + direct-selected anchors), Transform panel change, move of direct-selected anchors or of one anchor + (`path.moveAnchor`), new anchors from `path.setAnchors`, live shape change (`object.setLiveShape`), move of a + compound path's path on its own, and artboard move or resize that carries the art (`artboard.move` with + `moveArt`, `artboard.setProps` with art, Rearrange Artboards) snaps those segments again, as do the art copies of `artboard.move` with `copy` and Duplicate + Artboards, the copies that `edit.duplicate` and the paste commands move, and `object.slice.move` of an object slice, + and so does every `stroke.set`, `stroke.weightUp` and `stroke.weightDown` edit, which also rounds the new weight. An + appearance edit that adds, removes, hides, repaints or reweighs a stroke snaps them again too: + `appearance.setItem`, `appearance.addStroke`, `appearance.removeItem`, `appearance.clear`, + `appearance.reduceToBasic`, `appearance.showAllHidden`, `appearance.transfer` (both objects), `appearance.copyFrom` + (the Eyedropper), `paint.setStroke` and `graphicStyle.apply`, in the same undo step as the edit, as is every member + of an active Global Edit that such an edit or move reaches. Every other command that changes a pixel-aligned object + (for example Object › Path › Average) snaps it again as part of its undo step. Each anchor that such a segment shares moves with its handles, so diagonal segments and curves keep their shape apart from those + anchors. A rotated shape with no horizontal or vertical segment is left as it is. Pixel-aligned art of a linked + opacity mask snaps when its object moves. +- Coordinates that differ stay at least 1 pt apart within a subpath, so a narrow shape does not collapse. A + coordinate on a whole point snaps to the half point above it and one on a half point to the whole point below it, so + hiding and showing an odd stroke returns the path to where it was. A placed gradient and the art of a linked opacity + mask move with the snapped anchors, and so do pattern tiles when the move transforms patterns. A pixel-aligned member + of an Objects on Path group snaps again after the group places it on its path. +- A live rectangle, polygon, line or pie is moved and scaled as a whole to the snapped bounds, and stays live. A full + ellipse has only curves and is left as it is. A path with Live Corners snaps the outline its corners are cut from and keeps + its Live Corners. A compound path snaps each of its paths with the compound path's stroke. +- Make Compound Path from paths or compound paths that include a pixel-aligned one gives a pixel-aligned compound path + and snaps it; its paths do not keep the property, and a path inside a compound path cannot take it alone (an error). + Release Compound Path gives each released path the property of the compound path. A path pasted into a compound + path, alone or from a pasted compound path, drops its own property and snaps with the compound path. + +Turning the property on snaps the objects at once; turning it off keeps their geometry. These rules are VectorCraft's +own: only paths and compound paths can be aligned, and type, images, groups, guides and other objects are an error +that leaves the document as it was. The change is one undo step, and when every object already has the value nothing +changes and no undo step is made. The property is saved in the native file (`pixel_aligned` in `document.node`), and +files saved without it read back with it off. + +```json +{"name":"run_command","arguments":{"command":"object.setPixelAligned","params":{"on":true}}} +{"name":"run_command","arguments":{"command":"object.setPixelAligned","params":{"ids":[12],"on":false}}} +``` + ## Live Corners `object.setLiveShape {id?, ids?, radius?, kind?, corners?, items?}` sets the corners of any path (one undo step): a live @@ -3461,6 +3777,13 @@ They list such a command while the user has changed its shortcut (`overridden: t shortcut for that command runs what a click on the item runs: Object › Path › Clean Up…, Print Ink Fixes… and Edit › Find and Replace… open their dialogs. +Each installed filter plug-in's Object › Plug-ins item is its own row, with id `plugin:<id>` (the plug-in's id), its +menu label and no default shortcut, so two filters with the same name have two rows. `shortcuts.set {id: +"plugin:<id>", shortcut}`, `shortcuts.list` and Import Shortcut Set use that id, `shortcuts.exportText` writes one line +for the row under its menu path and label, and the key runs what a click on the item runs, `plugin.dialog {id}`. A +removed plug-in's row goes away and its key runs nothing; its shortcut stays saved and applies again when a plug-in +with the same id is installed. + `shortcuts.exportText {path?}` saves the current set's shortcut list as a UTF-8 text file, as Edit › Keyboard Shortcuts › Export Text… does with the dialog's set as edited so far. The file starts with a "Keyboard Shortcuts" line and a "Set:" line with the set's name, followed by a Tools section and a Menu Commands section. Each section lists the @@ -3486,7 +3809,16 @@ Actions panel plays an action as a `command.batch`, omitting steps whose playbac cleared. An action set saved as `.vcactions` keeps each excluded step with `excluded: true`; loading an earlier version 1 set without that flag includes every step. The Actions panel menu's Batch… runs `action.batch`: the same steps on every readable file in a folder (or every data set of the open document), with -destination `none` | `saveAndClose` | `folder` and Override Open / Save / Export. For a Data Sets source, +destination `none` | `saveAndClose` | `folder` and Override Open / Save / Export. With a Data Sets source, +`saveAndClose` saves the open document over its own file after each data set, so the file keeps only the last one, +and a document that was never saved, was converted from an older version or was restored by Data Recovery is +refused before a data set is selected, unless a recorded Save or Save As step (not in the template format) writes a +file and makes it the document's own first. When `destinationFolder` is set and Override Save points a recorded Save or +Save As step into it, that step makes each data set's file the document's own, and `saveAndClose` saves each data +set to its own file. The result carries `warning` when every data set saves over the same file: +with `saveAndClose` without such a step, or with a Save, Save a Copy, Save as Template or export step (PDF, EPS, DXF, Save for Web and +Save for Office Documents included) that writes a file and that the batch does not point into `destinationFolder`. The dialog shows the same +warning. For a Data Sets source, `datasetFileNaming?: fileNumber | fileDataSetName | dataSetName` chooses File + Number (`source001`), File + Data Set Name (`source_Alpha`), or Data Set Name (`Alpha`). An omitted parameter keeps File + Data Set Name for existing callers; the dialog starts with File + Number. A folder source ignores this parameter. The folder @@ -3656,6 +3988,12 @@ desktop app): in View › Pixel Preview, on softens placed and embedded bitmaps; `disableAutoAddDelete` (off by default): on, the Pen no longer adds or deletes an anchor when a click lands on a segment or an anchor of a selected path. +`ctrlWheelScrollsSideways` (on by default): on, Cmd/Ctrl-wheel scrolls the canvas sideways instead of zooming while +`zoomWithMouseWheel` is off; off, Cmd/Ctrl-wheel zooms about the pointer. Alt + Cmd/Ctrl-wheel and a pinch on a Mac +trackpad, on a touch screen or in the web build still zoom about the pointer. With `zoomWithMouseWheel` off, once a +trackpad gesture holds Alt, it zooms until it ends, also after Alt is let go. The control channel's +`ui.wheel` with `cmd: true` turns the wheel with Cmd/Ctrl held. + `gridlineEvery` (72 pt by default) and `gridSubdivisions` (8 by default) set the document grid that View › Show Grid draws, and View › Snap to Grid snaps to its subdivisions. Changing either sets the grid of every open document. `file.new` gives a new document that grid, and `document.open`, `file.newFromTemplate` and `file.revert` give it to @@ -3750,6 +4088,9 @@ Pen's second anchor at (150, 431), in line with the square's centre. - `placeholderText` (on by default): type the Type tools place (`pointer_gesture` with `type`, `areaType`…) starts with placeholder text, selected, so `type_text` replaces it; `text.create` and `text.createInPath` take `placeholder: true`. +- `typedLists` (on by default; Type › Start Lists From Typed Markers): a list marker and a space typed with the Type tool + at the start of a paragraph start a list (see `text.startList`). Off, the Type tool leaves them as typed text; + `text.startList`, `text.setFormat {list}` and the Bullets and Numbered List buttons work as before. - `typeSizeIncrement`, `trackingIncrement`, `baselineShiftIncrement`: what `type.step {attribute: "size" | "leading" | "tracking" | "kerning" | "baselineShift", by?}` steps by, on the text range given, the type objects in `ids`, the Type tool's selected text or the selected type objects. `type.size.increase` / `type.size.decrease` (Cmd+Shift+. and @@ -3857,8 +4198,10 @@ New objects take the fill and stroke proxies (`paint.setFill` / `paint.setStroke selected) on top of a template. With nothing selected, `stroke.set` / `stroke.setAdvanced` (cap, join, dashes, arrowheads, profile…) and `graphicStyle.apply` (every fill, stroke and effect, opacity, blend mode and the link to the style; `add: true` stacks it) set that template instead of editing art. With the Appearance panel's New Art Has Basic -Appearance off (`appearance.setNewArtBasic {on: false}`, the preference `newArtBasic`) new art takes the whole -appearance of the last selected object. `appearance.newArt` reports what the next object gets; `paint.default` +Appearance on (the default), a `select.*` command or `layer.target` that changes the selection loads the proxies and +the weight from it: only a paint or weight the selected objects share, and art without paint (a placed image that isn't +colorized) changes nothing. With the option off (`appearance.setNewArtBasic {on: false}`, the preference `newArtBasic`) new art takes the +whole appearance of the last selected object. `appearance.newArt` reports what the next object gets; `paint.default` resets it. `appearance.setNewArtBasic` rejects an `on` value that is not a boolean. Omitting `on` toggles the current preference. @@ -3930,7 +4273,7 @@ field with text clears it, and Escape in the empty field closes the dialog. `document.setup` with no params reports the document setup: units, the bleed ([top, bottom, left, right] in pt, drawn as a red outline around each artboard), the transparency grid (size and two colours; the first is also the simulated paper colour), the flattener preset and Discard White Overprint (Overprint Preview keeps white overprints visible while -it is on), the type options (language and its quotes, Use Typographer's Quotes for typed quotes, superscript, subscript +it is on), the type options (language and its quotes, Use Typographer's Quotes for typed straight quotes and a typed `”` where a quote opens, superscript, subscript and small caps proportions, SVG text export) and the background contents (white: raster exports are white behind the art). Pass any of these keys to change them in one undo step; `document.setUnits` stays as an alias for `units`. A file whose bleed (0 to 72 pt a side), superscript and subscript sizes (1 to 500%) and positions (-500 to 500%) or @@ -4084,6 +4427,15 @@ selects them. `edit.paste {artboardsAt: [x, y]}` instead centers the copied artb value that is not `[x, y]` fails with `` `artboardsAt` must be [x, y] ``. `artboard.duplicate {index? | indices?, art?}` does the same in one step → `{index, indices, ids}`. Window › Artboards › Duplicate Artboards passes the selected artboards (those the Artboard tool has selected, else the active one) as `indices`, and a row dragged onto New Artboard passes its artboard as `index`. +`artboard.new {beside?, side}` adds an empty artboard the size of artboard `beside` (default: the last), 20 pt from +that side of it, and `artboard.duplicate {index, side}` puts the copies there instead, together as they lay on that side +of the artboards they copy; `side` is `"left"`, `"right"`, `"top"` or `"bottom"`. Another `side` fails with +`` `side` must be "left", "right", "top" or "bottom" ``, `beside` without `side` fails with `` `beside` needs `side` ``, +and `artboard.new` refuses `side` given with `x`, `y`, `width` or `height`; each leaves the document and its undo +history as they were. With the Artboard tool and one artboard selected, a button with a plus shows just outside the +middle of each edge where an artboard of the same size fits on that side (within the canvas and overlapping no other +artboard): a click runs `artboard.new {beside, side}`, an Alt-click `artboard.duplicate {index, side, art: true}`, and +the new artboard becomes the active one. An Alt-click on a locked artboard's button does nothing. `artboard.move {copy: true, moveArt: true}` is the Artboard tool's Alt-drag. `artboard.duplicate` and `artboard.move` also move the pattern tiles of the copies when `patterns` is true (default: prefs `transformPatternTiles`); `edit.paste` leaves the tiles of a pasted artboard's art where they were. @@ -4165,6 +4517,13 @@ When a call to `artboard.duplicate`, `artboard.copy`, `artboard.cut`, `artboard. names no artboard, its journal entry records the artboards it acted on as `selectedArtboards`. A replay of the journal acts on those artboards, and a recorded action leaves the value out and acts on the selection when it plays. +`edit.copy` and `edit.cut` with the Artboard tool chosen record the same `selectedArtboards`, and `art` beside it. +`edit.copy {selectedArtboards: [n…], art?}` and `edit.cut {selectedArtboards: [n…], art?}` run `artboard.copy` and +`artboard.cut` on those artboards whichever tool is chosen and whatever is selected, so a replay of the journal in a new +session takes the same artboards. A value of `selectedArtboards` that names no artboard fails as `indices` does, and `art` +that is not true or false fails. A recorded action leaves both values out: when it plays, `edit.copy` and `edit.cut` take +artboards when the Artboard tool is chosen then, and the selection otherwise. + `artboard.selectAll {}` chooses the Artboard tool when another tool is in use and selects every artboard. The active artboard stays active, the art's selection stays as it is, and no undo step is recorded → `{selected: [index…]}`, the active artboard first. @@ -4348,7 +4707,7 @@ image `object.rasterize` makes with the same options. ## 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, Targa, PSD, PSB, PCX, Pixar or JPEG 2000 file, decoded within the pixel allowance the +`{name?, dataBase64}`: a PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PSD, PSB, PCX, Pixar, JPEG 2000, HEIC/HEIF 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 @@ -4373,6 +4732,14 @@ its file was found. Only files on this computer, or on the network share the doc on, are looked at: a path to another computer, a device or a URL counts as missing. On Windows, a document opened from a mapped drive (`Z:\…`) is on the share the drive is mapped to, and links on that share (`\\server\share\…`) are looked at. A link on a drive mapped to another share (`Y:\…`) counts as missing, from a document on this computer too. +The links of an open document keep their relative paths from the file it was opened from (for a `.ai` file another +app wrote, that file), also after a save to another folder. When a link's file is not at its path, a save and Package +write the path relative to the new file of the place the file is found, or for a missing file of the place that +relative path names, and a document made with New Document from State keeps the same places, so a document moved while +its linked files were missing finds them once they are back in that folder. In the open document, a link +is looked for at its path, then at its relative path from the folder of the file the document was opened from and from +the document's own folder, then by name in those two folders. A link found away from its path takes the relative path +of the place it was found. The result lists `missingLinks` (their images show the preview), `modifiedLinks` (left as they were; read again only with the preference `updateLinks: "automatically"`, then they are in `updatedLinks`), each as `{name, path, ids}`, with `refused` (why the @@ -4580,8 +4947,12 @@ and every stroke a line. `alterPaths` writes as filled outlines those strokes (w wider than the widest lineweight (2.11 mm), and every stroke in R13 and R14, which have no lineweights; it keeps other strokes as lines. `outlineText` outlines type, `selectedOnly` writes only the selected objects in their layers (every export takes it), and `useArtboards: true` writes one drawing per chosen -artboard holding the art over it. What DXF can't hold (gradients and patterns as one colour, blending, opacity masks, -raster effects, clipping) comes back in `warnings`. In R13 and later, an image that can't be decoded is left out with +artboard holding the art over it. `dxfPreset: "cnc"` (CNC / CAM; default `"custom"`) sets the options for cutting +software that the call does not give: version `2000` (the oldest version that has both lightweight polylines and lineweights), `unit: +"mm"` and `scale: 1`, `colors: 256` (all version 2000 holds), `preserve: "editability"` with `alterPaths: false` (every +stroke a center line), `outlineText: true` (type as paths), and `rasterFormat: "png"`. DXF Options has the same choice as its Preset menu, +which shows Custom again once a field it sets is changed. What DXF can't hold (gradients and patterns as one colour, +blending, opacity masks, raster effects, clipping) comes back in `warnings`. In R13 and later, an image that can't be decoded is left out with no linked file, and `warnings` carries "an image couldn't be decoded and is missing from the picture" once for all such images. @@ -4592,6 +4963,7 @@ opened. ```json {"name":"run_command","arguments":{"command":"document.exportDxf","params":{"path":"/tmp/plan.dxf","version":"2013","unit":"mm","scale":10,"preserve":"editability"}}} {"name":"export","arguments":{"path":"/tmp/plan.dxf","options":{"useArtboards":true,"colors":256}}} +{"name":"run_command","arguments":{"command":"document.exportDxf","params":{"path":"/tmp/sign.dxf","dxfPreset":"cnc"}}} ``` ## DXF import @@ -4663,8 +5035,13 @@ document, objects, graphicStyles, brushes, spotColors, patterns, gradients, symb embeddedImages, repeats), `brushNames` (the Brushes panel's brushes; with `selectionOnly`, the brushes the selected objects' strokes use), `repeatObjects` (`[{id, name, type, instances, rows?, cols?}]`: every Repeat object, those in groups and in other repeats included, with its type `radial`, `grid` or `mirror` and the number of instances it makes, -and a grid's `rows` and `cols`; with `selectionOnly`, the Repeat objects in the selection) -and `pathLength` (pt: the total length of the paths, compound paths' pieces included and guides left +and a grid's `rows` and `cols`; with `selectionOnly`, the Repeat objects in the selection), `patternObjects` +(`[{id, name, patterns}]`: every object filled or stroked with a pattern, once each, those in groups and type objects +whose drawn characters use a pattern included, hidden fills and strokes, strokes with no width, guides, a compound path +member's own fills and strokes, an image's fills and strokes that no Convert to Shape or Outline Object effect gives a +shape, and characters that draw nothing (spaces and line breaks without an underline or strikethrough) left out, with +the patterns it uses, which the patterns category lists as Pattern Objects and `objects.patterns` counts; with `selectionOnly`, those in the selection), +`patternNames` (the patterns those objects use) and `pathLength` (pt: the total length of the paths, compound paths' pieces included and guides left out, which objects lists as Path Length in the document's units, say for a cut line or an LED strip along the letters); `format: "text"` returns the plain-text report Document Info › Save… (`docInfo.save {path?, selectionOnly?, category?}`: only `category` when given, else every category) and the package report write. @@ -4708,7 +5085,8 @@ The Slice tool (`slice`, Shift+K) and the Slice Selection tool (`sliceSelection` `object.slice.create {x, y, width, height}` (the Slice tool's drag; it snaps to ruler guides), `object.slice.select {slices?, toggle?}`, `object.slice.move {slices?, dx, dy}` (an object slice moves its object), `object.slice.setRect {id, x, y, width, height}` (user slices) and `object.slice.delete {slices?}`. Slice ids are user -slice ids and the ids of objects with an object slice. +slice ids and the ids of objects with an object slice. `object.slice.select` with `slices` left out or null deselects +the slices. ```json {"name":"run_command","arguments":{"command":"object.slice.create","params":{"x":0,"y":0,"width":200,"height":80}}} @@ -4785,7 +5163,8 @@ without `useArtboards` (no central file when no art shows). `level: 3` (default) shadings and masks transparent image pixels out; `level: 2` writes gradients as bands of colour (as does `compatibleGradients`) and transparent pixels white. Type is always glyph outlines, so no fonts are needed (`embedFonts` is accepted); spot colours are Separation colour spaces, and overprinting fills and strokes overprint unless -`overprints: "discard"`. RGB documents are written in CMYK unless `cmykPostScript: false`. When the file would hold an +`overprints: "discard"`. RGB documents are written in CMYK unless `cmykPostScript: false`. A freeform gradient is +written as an image, in CMYK when every point is CMYK or when every point is Gray in a file written in CMYK. When the file would hold an image that can't be decoded, the image is left out, and `warnings` carries "an image couldn't be decoded and is missing from the picture" once for all such images. Colorized grayscale and 1-bit images use their process or spot ink; the flattener leaves them alone unless they have opacity, a blending mode, an opacity mask or effects, or overlap other transparent art. Binary coverage leaves underlying art untouched. For a grayscale image, Level 3 leaves white (zero-coverage) pixels out, Level 2 writes them white, and gray midtones are written over white at both levels. @@ -4823,6 +5202,12 @@ documents every field: visiblePrintable|visible|all` (template layers never print); `placement: {origin, x, y}` places the printed area (artboard, bleed and marks) on the imageable area (the paper inside `margin`); `scaling: none|fit|custom|tileFull| tileImageable` with `scale: {width, height}` (%), `overlap` and `tileRange` (tiles numbered across then down). +- Copies per sheet: with `copiesPerSheet: true` (off by default; not with tiling) each sheet holds as many copies of + the artboard as fit the imageable area, each with its bleed and marks, across then down, `gutter` pt apart (0–720, + default 0). The copies are not turned, and the placement places the whole grid. An artboard that doesn't fit prints + one copy per sheet, with a warning; more than 1,000 copies on a sheet is an error. `print.preview` lists each + copy's artboard on the page as `copies: [[x0, y0, x1, y1]…]`, across then down, and `trim` (the TrimBox) holds + them all. - `marks` and `bleed` as `document.exportPdf` takes them (the bleed is the document's unless `useDocument: false`), drawn around the artboard at the paper's scale; the page information adds the tile and the ink with its screen. - `output: {mode: composite|separations, emulsion: up|down, image: positive|negative, spotsToProcess, inks: [{name, @@ -4902,8 +5287,12 @@ or in `options` (`file.formatOptions {format}` lists them with their values): - `includeLinked: true` keeps linked images' own pixels in the file (they stay linked), not just the previews. - `embedProfiles` (default true) carries the ICC profiles the document is tagged with that were loaded from files; opening the file installs them where they are missing (a warning names one that can't be used). -- `pdfCompatible`: native files (default false) also carry a PDF of every artboard (`pdf` in the file); `.ai` files +- `pdfCompatible`: native files (default true) also carry a PDF of every artboard (`pdf` in the file). This makes saves + larger and adds PDF rendering work. Set it to false to leave the PDF out; Save remembers that choice. `.ai` files (default true) with false write blank pages around the native document (smaller; other apps show empty pages). + Native files without artboards and Data Recovery copies omit the PDF. + Reopening a native file keeps its saved PDF preference, falling back to whether it declared a PDF in older files. + Existing files saved without a PDF remain compact. - `.ai` only: `compress` (default true) is the PDF option `compression.compressText`. - The editing copy of a `.ai` or `.ait` file (`editingData`) names its active artboard (`CropAreaActive`): the active artboard of the app's view when the app saves or exports the file, otherwise the artboard the document opened on (the @@ -5027,6 +5416,38 @@ renderer measures for what the art draws, its effects included. A layer id in `ids` (12 above) is the same as Collect for Export on that Layers row: every object under the layer becomes an asset (or one asset with `multiple: false`). +### 3D assets + +Asset Export's menu item Export 3D Object (`assets.add3D {ids?, format?}`) collects each selected object (or `ids`) +that carries Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, named after the object; an object already +collected as a 3D asset keeps it, and an object without such an effect is refused. A 3D asset's Format is `usda` (the +default), `usdz`, `gltf` or `obj`; the panel shows a Format row for the 3D assets it would export, and +`assets.set3DFormat {assets, format}` sets it (an undo step only when the format changes). `assets.list` gives each +asset's `format3D` (null for a picture asset). + +`assets.export3D {assets?, folder?, zip?}` writes each 3D asset (default: all of them) as a mesh file named after it: +`.usda`, `.usdz`, `.gltf` (glTF 2.0, its buffer embedded) or `.obj` with its `.mtl` (whitespace and `#` in an OBJ's +name become `_`). Every solid of the object (the +fill's, each stroke's, a group's members') is its own mesh, with a two-sided material in the color it is painted +(a gradient's stops averaged, a pattern gray). The mesh holds the solid's faces before they are projected onto the +page, those turned away from the viewer included, at the object's 3D rotation, y up, about the point the object turns +about: in points (USD's layer gives `metersPerUnit`), in meters in glTF. Files that would share a name get `-2`, `-3` and so on. +With `zip`, the files go into one ZIP named after the document with `-3D` added (`Untitled-1-3D.zip`), or +`3D Assets.zip` when the document's title gives no file name. +An object whose face has a hole that crosses the face's outline or another hole is refused, by `assets.add3D` and +`assets.export3D` alike, rather than exported with the hole filled. So is an object whose meshes would hold more than +2,000,000 triangles together, VectorCraft's own limit. +`assets.export` leaves 3D assets out and refuses one named in its `assets`. On the 3D export page +(<https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html>) +the 3D asset's Format is picked in Asset Export, with USDA first; the file layouts follow the glTF 2.0 specification +(<https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html>) and OpenUSD (<https://openusd.org/release/index.html>). + +```json +{"name":"run_command","arguments":{"command":"assets.add3D","params":{"ids":[12],"format":"gltf"}}} +{"name":"run_command","arguments":{"command":"assets.set3DFormat","params":{"assets":[40],"format":"obj"}}} +{"name":"run_command","arguments":{"command":"assets.export3D","params":{"folder":"/tmp/meshes"}}} +``` + ## CSS Properties `css.selection {ids?, units?, position?, dimensions?, unnamed?, rasterize?}` returns the CSS web pages style the @@ -5068,6 +5489,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"}}} @@ -5076,10 +5504,11 @@ again when it changes); Multichannel files are refused. ## Printing `print.setup {settings?}` keeps print settings with the document (one undo step; without `settings` it answers the -current ones): the copies, artboards, paper, orientation, placement, scaling and tiling, marks and bleed, composite or -separations output, graphics and colour management options of File › Print. `print.preview {settings?}` lays the job -out without printing: `pages`, then one copy's `sheets`, each with its page size, scale, the `transform` from the -document onto the page (pt, y down), the document `area` it prints and its `trim` box on the page, plus `tiles`, the +current ones): the copies, artboards, paper, orientation, placement, scaling and tiling, copies per sheet, marks and +bleed, composite or separations output, graphics and colour management options of File › Print. `print.preview +{settings?}` lays the job out without printing: `pages`, then one copy's `sheets`, each with its page size, scale, the +`transform` from the document onto the page (pt, y down), the document `area` it prints, its `trim` box on the page +and, with copies per sheet, each copy's artboard on the page (`copies`), plus `tiles`, the `inks` of a separation and `warnings`. `file.print {settings?, path?}` writes the job as a print-ready PDF (one page per sheet, tile, ink and copy); headless it answers `dataBase64`. In the app, `file.print` with no params opens the Print dialog, and with params it prints through the system (`printer`: a name from `print.printers`, default the system's @@ -5125,7 +5554,7 @@ dictionaries (the standard ones are values in `systemdict`; `dictstack`, `intern intervals share storage, resources (categories made from `Generic`, `resourceforall`), executable filters (`cvx exec` runs their data; `flushfile` skips it), axial and radial shadings and shading patterns (gradients), images and image masks (data in the file through ASCII85, hex, run-length, Flate, LZW or DCT filters, or from procedures), and type as -point type in the font the file names (embedded font programs are skipped). In a file in Illustrator's own format +point type in the font the file names (embedded Type 1 and Type 42 fonts' characters and widths come from the font program they carry, and the show operators' spacing along the baseline is kept as tracking, while spacing that moves characters off it (`yshow`, `xyshow`) makes a text object per character; CID-keyed fonts draw the glyphs of the Type 0 and Type 2 CIDFonts the file carries, CIDFonts in a FontSet's Compact Font Format data included). In a file in Illustrator's own format (Illustrator 3–8 `.ai` and their EPS, written with the prolog that defines its operators) the groups it writes (`u` … `U`) come in as groups, nested as they were; clips and groups nest at most 128 deep. The artboard is the `%%HiResBoundingBox` (else `%%BoundingBox`; a letter page without one). A program the interpreter can't run (an operator it doesn't know, @@ -5464,6 +5893,26 @@ returns the new value. ``` +## Spelling + +`text.spelling.check {ids?}` lists the words of the document's type that its dictionaries don't know: every +visible, unlocked type object in paint order, or those in `ids` (and in the groups `ids` names). Each run is checked +in its Character panel Language (English when it has none) by craft-proof, one paragraph at a time; a language without +a spelling dictionary marks nothing. English words come from the first dictionary craft-proof finds: a Hunspell +dictionary in the user's dictionaries folder, the SCOWL dictionary for that English built in from craft-i18n, a +system Hunspell dictionary, and last the public-domain Moby word list built into craft-proof. Words in capitals, words with digits and web addresses are taken as correct. The result is +`{words: [{id, start, end, word, lang}], more}`: `start` and `end` are byte offsets in the type object's text, the +ones `text.editRange` takes, so `text.editRange {id, start, end, insert}` changes a word as one undo step. At most +10,000 words are listed; `more` is `true` when there were more. `text.spelling.suggest {word, max?, lang?}` gives +the words the dictionary of `lang` (default English) suggests instead of `word`, best first, at most `max` (1 to 20, +default 5). Both only read the document. + +```json +{"name":"run_command","arguments":{"command":"text.spelling.check","params":{}}} +{"name":"run_command","arguments":{"command":"text.spelling.suggest","params":{"word":"drawng","lang":"en"}}} +``` + + ## Justification options `text.setFormat` sets the Paragraph panel menu's Justification… options of the selected type (or `ids`, or the @@ -5522,6 +5971,30 @@ 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) moves the +character before a prohibited line start to the next line with it. With Burasagari on, an overflowing comma or full +stop may hang outside the margin first, leaving the preceding character on the line. With Kinsoku Set None the setting +does nothing. When Push In First or Push Out First is selected and a paragraph contains a prohibited line-start or +line-end character, the whole paragraph is broken line by line, as the Single-line Composer breaks it, even if no line +ends up pushing a character in. 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 / @@ -5556,6 +6029,53 @@ dialog open. {"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":0,"end":4,"tateChuYoko":true,"tcyUpDown":2,"tcyLeftRight":-1}}} ``` +## Composite Fonts + +A composite font sets each class of character in a font of its own +([Creating composite fonts](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347)). +`type.compositeFonts.set {fonts: [{name, parts}]}` replaces the active document's composite fonts as one undo step, +and adds no step when nothing changes. `parts` holds `kanji` (the base font), `kana`, `punctuation`, `symbols`, `roman` +and `numerals`, each `{font, style?, size?, baseline?, hScale?, vScale?, scaleAtCenter?}`; a part left out copies the +`kanji` part. Which characters fall in each class is VectorCraft's own rule: Kanji takes ideographs, full-width +letters and digits and every character no other class takes; Kana takes hiragana and katakana of both widths; +Punctuation takes East Asian and full-width punctuation, quotes and dashes; Symbols takes arrows, math, shapes and +other symbols; Roman takes half-width letters, their punctuation and spaces; Numerals takes the digits 0 to 9. + +`size` (10 to 400, 100 by default) and `baseline` (-100 to 100, 0 by default; positive raises) are percentages of the +type's size, VectorCraft's own ranges. `hScale` and `vScale` (10 to 400, 100 by default) scale Kana, Roman and +Numerals only, and `scaleAtCenter` (Kana only) scales kana from the center of the em box instead of the baseline. A +value out of range, a scale on another class, an unknown class or key, a name that is empty, longer than 255 +characters, shared by two fonts (in any case) or the name of an installed font, and a part set in a composite font are +each refused with an error, and nothing changes. A document keeps at most 256. + +Type set in a composite font names it as its family (`text.setStyle {font: "My Composite"}`), and the font menus list +composite fonts first. The layout sets each character in its class's font, a letter and its combining marks together. +A composite font taken out of the document leaves its type naming it, and the type shows as missing. Composite fonts +are saved with the document. SVG and legacy `.ai` write type in one of the document's composite fonts as outlines, and +SVG, PDF, EPS and legacy `.ai` export draw it in the font's parts also when the document was read without an open app. +`type.compositeFonts.list` returns the document's composite fonts. + +`text.replaceFont` and `addon.cleanup.replaceFonts` with a composite font as the new font set the type in it by its own +name, and the type keeps its style. `text.fonts` lists a composite font by its name; its `status` is the worst among the +parts its characters are drawn in, and `missingGlyphs` counts the characters that their part's font lacks. +`text.missingFonts`, File › Package and the warnings about fonts that aren't available name the part fonts that the +characters are drawn in. `text.glyphAlternates` lists the alternates of a character from its part's font. + +`type.compositeFonts.export {path?, names?, fonts?}` writes the document's composite fonts (all, or those `names` +lists), or the `fonts` given, to a `.vccomposite` file; without `path` it returns the file's text as `data`. +`type.compositeFonts.import {path | data | dataBase64, apply?}` reads a file of at most 4 MB. With `apply` (the +default) its fonts join the document's, replacing a font of the same name, as one undo step; with `apply: false` it +only returns them. Opening a `.vccomposite` file imports it. + +Type › Composite Fonts… (shown with Show East Asian Options) opens the Composite Fonts dialog on the document's fonts: +Font picks one, and a table sets each class's font, style, size, baseline and scales. New adds a font based on the one +picked, Delete Font takes it out, Import… adds the fonts of a file and Export… writes the one picked. OK runs +`type.compositeFonts.set` with every font as one undo step; a refused font keeps the dialog open. + +```json +{"name":"run_command","arguments":{"command":"type.compositeFonts.set","params":{"fonts":[{"name":"My Composite","parts":{"kanji":{"font":"Source Han Sans"},"roman":{"font":"Source Sans 3","size":95,"baseline":-2},"numerals":{"font":"Source Sans 3"}}}]}}} +``` + ## Optical Margin Alignment `type.opticalMarginAlignment {on?: bool}` (Type › Optical Margin Alignment) turns Optical Margin Alignment on or off @@ -5641,13 +6161,37 @@ the text. Return at the end of a list item starts another item in the same list. set, and paragraph styles carry it: a style captured from a paragraph without a list turns the list off. Reset Panel turns it off. The change is one undo step. +`text.setFormat {listAlign: "left"|"right"}` (Bullets and Numbering › Alignment) sets where the marker stands before +the first line's text. With `"left"`, the default, the marker starts where the first line starts, at the left indent +plus the first-line indent. With `"right"` the marker ends where the text starts, against the text. The text starts at +the same place with either alignment, so numbers of different widths aligned right end at the same place, and a number +that reaches past the left indent ends at the next tab stop, where the text starts. In a right-to-left list paragraph a +marker aligned right starts where the text ends. `listAlign` sets the paragraphs that are in a list, and those that the +same call puts in one; a paragraph in no list stays out of one. A list keeps its alignment when it changes between +bullets and numbers, and a list that is turned off and on again starts aligned left. An unknown value is an error and +changes nothing. The list saves the alignment (`"align": "right"`) only when it is right. + +`text.setFormat {listLevel: 1..9}` (Bullets and Numbering › Level) sets the level of the list paragraphs it touches, and +of those that the same call puts in a list; a paragraph in no list stays out of one. The value is a whole number from 1 +to 9 (`2` or `2.0`); any other value is an error and changes nothing. Each level past the first +sets the paragraph 18 pt further in than its left indent: its marker, its text and the lines it wraps to move together, +and in a right-to-left list paragraph they move in from the right. A list keeps its level when it changes between +bullets and numbers, and a list that is turned off and on again starts at level 1. The list saves the level +(`"level": 3`) only when it is not 1. In the Type tool, Tab with the caret at the start of a list paragraph (before its +first character, with nothing selected) runs `text.setFormat {listLevel}` with the next level, and Shift+Tab with the +level before; each is one undo step, and the text is unchanged. Tab at level 9 and Shift+Tab at level 1 do nothing. +Anywhere else, with characters selected, or in a paragraph in no list, Tab and Shift+Tab insert a tab. + A numbered paragraph's number counts the numbered paragraphs of its story up to and including it: the count runs through consecutive list paragraphs, through the frames of threaded type in thread order, and starts again at 1 after a paragraph in no list. An empty paragraph has no number and is not counted, and a bulleted paragraph keeps its bullet and -is not counted. Letters run from A to Z, then AA to ZZ, then AAA; roman numerals use IV, IX, XL, XC, CD and CM; 01 and +is not counted. Each level counts its own numbers: a list paragraph that has text sets the counts of the levels +deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a +shallower level goes on with that level's count. Letters run from A to Z, then AA to ZZ, then AAA; roman numerals use IV, IX, XL, XC, CD and CM; 01 and 001 keep every digit past 99 and 999; letters and roman numerals past 3999 are written in Arabic digits. In a right-to-left paragraph the number reads left to right with its period at its left. Each threaded frame saves the count -that its earlier frames carry into it (`listCount`, only when it is not 0). Type that is in no thread counts from 1: +that its earlier frames carry into it at level 1 (`listCount`, only when it is not 0) and at the levels past it +(`listLevelCounts`, level 2 first, only when one of them is not 0). Type that is in no thread counts from 1: after Remove Threading or Release Selection, in a frame converted to point type, and in every copy of a frame (pasted, duplicated, copied in the Layers panel, or taken out of a symbol or a placed file). A symbol made of a thread keeps the numbers in the symbol's art. @@ -5668,12 +6212,29 @@ Type › Bullets and Numbering › Apply Bullets and the canvas context menu's B same menus runs `type.applyNumbers {number?}`. Without `number` it runs `text.setFormat {list: "number"}`, and with it it runs `text.setFormat {number}`. +More Options at the end of the Paragraph panel's Bullets and Numbered List presets runs `ui.bulletsAndNumbering {tab?: "bullets"|"numbers"}`. It opens the Bullets and Numbering +dialog (`bulletsAndNumbering`) on the paragraphs the Paragraph panel acts on, on that tab; without `tab` it opens on +Numbers when the paragraphs are a numbered list and on Bullets otherwise. On both tabs the Level field, above the +presets, holds the paragraphs' list level (1 to 9; 1 for paragraphs in no list). The Bullets tab has the five bullet +presets and the Numbers tab has None and the seven number styles. The Position group has Alignment (Left or Right), +Left Indent and First Line Indent. The dialog opens with the values the Paragraph panel shows; where the paragraphs +differ, no preset is picked and the field is blank. Its fields are `tab`, `list` (`none`, `bullet` or `number`: the +kind of the preset picked), `bullet`, `number`, `listAlign`, `listLevel`, `leftIndent` and `firstLineIndent` (points), +and `ui.dialog.set` sets them, including those the dialog opened blank or without. The type shows the dialog's values while it is open. A preset picked for paragraphs that are in no list and have +no indents fills in 18 pt and -18 pt, and None takes them out again while they are unchanged. OK runs `text.setFormat` +with the values that changed, as one undo step, and Cancel puts the type back as it was. When a preset puts the +paragraphs in a list, OK also sends the indents the dialog shows, and the paragraphs take them, 0 included. OK does not +send an Alignment or Level change for paragraphs that stay in no list. With no type selected, or +with an unknown `tab`, `ui.bulletsAndNumbering` is an error. + Type › Bullets and Numbering › Convert to Text runs `type.convertListToText` on the selected type's paragraphs, or on those the Type tool's selection touches, and that runs `text.convertListToText {ids?, start?, end?}`. The marker of each list paragraph becomes characters at the start of the paragraph: the bullet, or the number and its period as the paragraph showed it, then a tab. They are set in the marker's font, size and color: the style of the paragraph's first -character without the attributes the marker leaves out. The paragraph leaves its list. When the marker's tab went to -the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, so that +character without the attributes the marker leaves out. The paragraph leaves its list. A paragraph at a list level +past the first keeps the level's indent in its left indent, so its marker and text stay where they were. A marker aligned right +(`listAlign` `right`) is written the same way: its characters go to the first line's start, where a marker aligned left +is drawn, and the text after its tab stays where it was. When the marker's tab went to the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, so that the text after the tab starts where it started after the marker. When the marker's tab went to a tab stop, the converted tab goes to the same place when that stop is left-aligned and no stop there or past it has a leader. When a stop there or past it has a leader or is not left-aligned, or a right, center or decimal stop lies between the marker @@ -5703,18 +6264,23 @@ next to the same character, and that frame becomes the selection when it was not Typing starts a list too. When the Type tool types a space on its own (one key press, or `type_text {text: " "}`) right after `1.` or `1)`, `A.` or `A)`, or one of `*`, `#`, `>` and `+` at the start of a paragraph that is in no list, the typed characters and the space are removed and the paragraph becomes a numbered list in 1, 2, 3 or A, B, C, or a -bulleted list with the filled dot. A list started with `1)` or `A)` also writes its numbers with a period. A paragraph +bulleted list: `*` gives the filled dot (•), `+` the hollow circle (◦), `#` the filled square (▪) and `>` the dash +(–). A list started with `1)` or `A)` also writes its numbers with a period. A paragraph whose left and first-line indents are both 0 gets 18 pt and -18 pt. The tool runs `text.startList {id, at}`, with `at` the byte after the space, as an undo step of its own after the typing: one Undo puts the characters back as text with no list, and typing goes on after them; Redo removes them again, and typing goes on before the paragraph's text. The same characters later in a paragraph start nothing, and so do list characters that arrive in one piece with the space: a -paste, an IME commit, or `type_text` of a longer string. From a script, `text.startList {id, at?}` (`at` defaults to the end of the text) does +paste, an IME commit, or `type_text` of a longer string. With the `typedLists` preference off (Preferences › Type › +Start Lists From Typed Markers), the Type tool starts no list from typed characters. From a script, `text.startList {id, at?}` (`at` defaults to the end of the text) does the same with the text before `at` and returns `started` and `caret`. When the list starts, `caret` is the byte where the paragraph's text now starts; when the text before `at` starts no list, nothing changes, `started` is false and `caret` is `at`. ```json {"name":"run_command","arguments":{"command":"text.setFormat","params":{"bullet":"square"}}} {"name":"run_command","arguments":{"command":"text.setFormat","params":{"number":"upperRoman"}}} +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"listAlign":"right"}}} +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"listLevel":2,"start":4,"end":4}}} +{"name":"invoke_menu","arguments":{"command":"ui.bulletsAndNumbering","params":{"tab":"numbers"}}} {"name":"run_command","arguments":{"command":"text.convertListToText","params":{}}} ``` @@ -5742,6 +6308,38 @@ Setting superscript on subscript characters replaces the subscript, and setting replaces the superscript. When `document.setup {superscript, subscript, smallCapsSize}` changes the proportions, the characters take the new ones. One undo step. {"name":"run_command","arguments":{"command":"text.setFormat","params":{"position":"superscript","smallCaps":false}}} + +## Anti-aliasing method + +`text.setFormat {antiAlias: "none"|"sharp"|"crisp"|"strong"}` (the selected type, or `ids`) sets the Character panel's +Anti-aliasing Method, which decides how the edges of the characters are drawn as pixels: on the canvas, in raster exports +(Export As PNG, JPEG and the other raster formats, Export for Screens, Save for Web) and when Object › Rasterize or +Effect › Rasterize draws the type. The method belongs to the whole type object, and each frame of a thread keeps its +own method: a `start`/`end` range sets the whole frame, and threading frames or opening a file keeps each frame's +method. VectorCraft defines what each method does, on the pixels of the picture being drawn (the canvas at its zoom, +an export at its resolution): + +- `sharp`, the default, draws each edge pixel with the share of it the type covers, as before. +- `none` draws hard edges: a pixel takes the type's color where the type covers at least half of it, and nothing + elsewhere. +- `crisp` draws a pixel the type covers by a share c at `2c - 0.5`, kept between 0 and 1, so the edges are about half as + wide. +- `strong` draws a pixel the type covers by a share c at `2c`, at most 1, so the type looks heavier. + +The opacity of the type's paints stays as it is inside the characters. Type is drawn as Sharp draws it when it holds +inline graphics or color glyphs (bitmap or COLR), when its own fills or strokes in the Appearance panel include a brush +stroke or a blend mode other than Normal, when it overprints while Overprint Preview is on, and on the canvas while it +shows a substitution highlight. +With Preferences › General › Anti-aliased Artwork off, the canvas draws all its art with hard edges, type included, and +so does an export whose own Anti-aliasing option is None. SVG, PDF and EPS export draw the type unchanged. The native +document such a file carries (an EPS VectorCraft wrote, or an SVG or PDF saved with Preserve Editing) keeps each frame's +method, and opening the file restores it. +Reset Panel sets Sharp. Saved as `antiAlias` only when not `sharp`. One undo step. + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"antiAlias":"crisp"}}} +``` + ## Character Alignment `text.setFormat {charAlign}` (the selected type, or `ids`) and `text.setRangeStyle {id, start, end, charAlign}` (a range) @@ -5936,8 +6534,12 @@ Type on a path flows between a start and an end bracket, stored as fractions of Path Options: `end: null` puts the end bracket back at the end of the path (or once round a closed one); `flip` then turns the type to the other side of its path, the path running the other way and the brackets swapping ends so the type keeps its stretch of the path; `alignToPath` runs the `ascender`, `descender`, `center` or `baseline` (the -default) along the path; `spacing` (points) closes glyphs up round the outside of curves and opens them up round the -inside. With none of them it queries the first selected type on a path: +default) along the path, each character by its own font, size and vertical scale: `ascender` runs the top of the +font's em box along it (its ideographic em box, else an em box centered on the cap height), `descender` runs the +bottom of the font's bounding box along it, and `center` runs a line at half the height of the top of the font's H +above the baseline along it (in vertical type, `ascender` and `descender` move the characters half an em with their +horizontal scale, and `center` leaves them on the path); `spacing` (points) closes glyphs up round the outside of +curves and opens them up round the inside. With none of them it queries the first selected type on a path: `{start, end, flip: false, effect, alignToPath, spacing}`. The Options dialog shows Effect, Flip, Align to Path and Spacing and leaves the brackets where they are. As in the reference app, the Selection and Direct Selection tools show selected type on a path's brackets: dragging the start or end bracket (`pointer_gesture`) sets where the type @@ -6086,33 +6688,128 @@ WebAssembly plug-ins ([plugins.md](plugins.md)) are reached through commands; th them again at startup (`plugin.remove` deletes them there): `plugin.install {path | dataBase64}` installs one, `plugin.list` / `plugin.info {id}` describe them (parameters, 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 +undo step; it may change, delete or add objects, and the result is selected). A filter whose manifest lists `inputs` +(`type`, `image`, `symbolInstance`; `inputs` in its `plugin.list` entry) also lists `doc.read` in its `permissions`, so +installing it needs a person's approval. It is also given the selected objects of those kinds to read, in paint order from the +bottom, with at most 16 MiB of pixels and 16 MiB of type runs over one run (an object that doesn't fit what the objects +before it left comes with `pixelsOmitted` or `truncated`; an image that reaches the decoder counts toward the pixels +even when decoding fails). Every object counts toward the input's half of the plug-in's memory cap, ids, bounds, +transforms and names included: pixels and runs that don't fit come the same way, and an object whose other fields don't +fit makes `plugin.run` return a memory-budget error naming the object and its size, with nothing changed. A path or compound path that doesn't fit makes it return the +memory-budget error `the selection is too large to hand over`, and more than 100,000 of these objects make it return +an object-budget error. Its output returns +each of them as `{"id": …}` or exactly as given, otherwise `plugin.run` returns an error naming the object and the +field, and the document and undo history are unchanged. A live-effect plug-in is an effect with id `plugin.<id>`: `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.<id>` (listed by `document.formats`): +`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.<id>` (listed by `document.formats`): `document.export {format: "plugin.<id>", 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` +that no built-in format has. An importer adds a read-only file format `plugin.<id>` (listed by `document.formats` +with `read: true`, and `openExtensions` holds the extensions File › Open opens with it, without those a built-in format reads or that File › Open installs, imports or loads as something else, such as `.wasm` or `.ase`): `document.open {path | name, dataBase64, importer?: +"plugin.<id>", 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?, 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. With `path` the reply is `document.open`'s result; +on the desktop a picked file is read off the UI thread and the reply is `{background: true, status}`, and +`file.cancelOpen` stops the importer. 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) needs a person's approval, and `plugin.install` refuses `allowHeavy: true` +from a request. `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`) -installs only with `grant: [permission]` listing every one of them: show the user the list and ask first, and pass -the entry's `hash` as `hash` so that a module that changed since is refused. Without `grant`, `plugin.install` returns -an error naming the permissions needed. The grant is kept with the module's hash; a module -with the same id that lists no new permission installs without `grant`, and `plugin.remove` forgets the grant. A +needs a person's approval too, and `plugin.install` refuses `grant: [permission]` from a request. With a desktop +app connected, `ui.installPlugin` opens the approval dialog and returns `{pending: "pluginInstall"}`; `answer_dialog`, +`ui.dialog.confirm` and injected input can't confirm it. A `vectorcraft-cli mcp` started with `--allow-plugin-grants` +accepts `grant` and `allowHeavy` from any request, in that session only (headless; it can't be combined with +`--connect`, `--automation-read-root` or `--automation-write-root`). A server confined to folders has no plug-in +store, so it reads no `grants.json`, and a module that lists permissions or asks for a heavy budget does not install +there. Without `--allow-plugin-grants` and without a recorded grant for the module, `plugin.install` returns an error +naming the permissions needed. With `VECTORCRAFT_NO_PREFS` set there is no store, and a `grant` accepted under the +flag lasts for that session only. In other sessions the approval, or a `grant` accepted under the flag, is kept with the module's +hash, counts as approved in later sessions and in the desktop app, and covers only that module: the +same bytes install again without one, also from a headless session that reads the same `grants.json`, and a module +with other bytes has `needsApproval: true` and needs a new one even when it lists the same permissions. `plugin.remove` +forgets the approval, and so does installing a module that lists no permissions under that id: the earlier bytes then +need a new approval. A refused install keeps the grant. A failed write of the module to the plug-in store leaves the +grant in the store. A stored module that is disabled, or not read in Safe Mode, keeps the grant for its id. When +`plugin.remove` can't remove the grant from the plug-in store, it returns an error and the plug-in stays installed. A filter without `doc.write` or `state` that returns output needing them gets an error and the document is unchanged. A plug-in of API level 2 (`api: 2` in its entry) that lists `progress` may report its progress, see Cancel and write log lines: `notifications/cancelled` makes its `vc_cancelled` return 1, and its log lines go to the server's log -under its id. A module that imports anything else, or imports these without `api` 2 and `progress`, is refused by -`plugin.install` and `plugin.inspect` with an error naming the import. +under its id. One that lists `settings` or `cache` keeps settings or a cache of its own in the `Plug-ins` folder +(VectorCraft's own caps: 256 settings and 1 MiB, 512 MiB of cache): `plugin.list` and `plugin.info` give the cache's +size as `cacheBytes`, `plugin.clearCache {id?}` empties the cache of one plug-in or of all of them and returns +`{cleared: [id], bytesFreed}`, replacing a plug-in keeps both, and `plugin.remove` deletes both (also for an id that +isn't installed but still has them). Files it can't delete stay in the plug-in's folder renamed with `.removed`, where +no plug-in reaches them. A later `plugin.remove` tries again to delete them when a plug-in with that id is installed or +a storage folder for the id exists, and that deletion can fail too; for an id with only a `.removed` folder it does +not ([plugins.md](plugins.md#settings-and-cache)). A module with other bytes than the plug-in installed with its id, or, with none +installed, one whose id has settings or a cache in the +`Plug-ins` folder (`needsReplace: true` in its `plugin.inspect` entry), installs only with `replace: true`: ask the +user first, since it takes that plug-in's place and gets its settings and cache. Without it, `plugin.install` returns +an error naming the plug-in. The same module installs again without `replace`, and `replace: false` refuses a module +whose id is installed. A module that imports +anything else, or imports one of these without `api` 2 and the import's permission, is refused by `plugin.install` and +`plugin.inspect` with an error naming the import and the permission. `plugin.install` rejects a `replace` or `allowHeavy` value that is not a boolean, a `grant` that is not a list of permission names, and a `hash` that is not the module's SHA-256. `prefs.set` returns an error and changes nothing when a new `pluginsFolder` is missing, is not a folder or can't be listed. It does not check an empty value or the folder the preference already holds, and the web build does not check folders. +A filter or live effect may declare commands of its own ([plugins.md](plugins.md#commands)): each is the command +`plugin.<id>.<command>` while the plug-in is installed, listed by `command_list` and in its `plugin.list` entry +(`commands: [{id, command, label, params, paramsSchema}]`). Run it with `{ids?, params?}` (its parameters may also +be top-level keys): the parameters are checked before the plug-in runs, a run is one undo step named after the +command, and a run that traps or runs out of its instruction budget returns an error that starts with the plug-in's +name and the command's label and leaves the document and the undo history as they were. For `plugin.run` and plug-in +commands, `ids` left out or null means the selection, and an `ids` that is not a list of object ids is an error. + +Safe Mode starts VectorCraft without plug-ins: `vectorcraft --safe-mode`, `vectorcraft-cli --safe-mode <subcommand>`, or +the choice the desktop app offers when the last launch stopped while a plug-in was loading (Disable and Continue, Start +Normally, Start in Safe Mode). It names that plug-in without saying it caused the stop. Until the user picks Disable and +Continue or Start Normally, the next launch asks again, also after a `--safe-mode` launch, which asks nothing. +`vectorcraft --safe-mode` opens its files in a window of its own, and on Windows and Linux a file opened from the file +manager while the app is in Safe Mode starts a window with plug-ins. Files given on the command line, or opened from +the file manager while the choice is up, open after the user picks. On Windows and Linux, when the choice is Start in Safe +Mode, the files from the file manager open in a new window with plug-ins. `vectorcraft-cli --safe-mode mcp` serves a headless +session and refuses `--connect`. In Safe Mode no plug-in loads or +runs: `plugin.install`, `plugin.run`, `plugin.reload` and exporters return an error, and a live-effect plug-in draws its +object's geometry unchanged while the object keeps the effect: `effect.list` gives that effect `skipped: true`, and the +Appearance panel shows "(Safe Mode)" after its name and the plug-in's id in its tooltip. `effect.expandAppearance` returns an +error and changes nothing when an object it would expand holds such an effect, in it or in a symbol it places. `vectorcraft` rejects an option it does not know (such as +`--safe-mod`) instead of opening it as a file; `--` before a file name that starts with `-` opens it. The web build keeps +no installed plug-ins from one page load to the next and has no launch marker, so it never offers the choice. `plugin.safeMode` returns `{active, source, disabled, +pendingItem}`; a command does not turn Safe Mode on or off. `plugin.setEnabled {id | file, enabled}` turns a plug-in on or +off for the next launch, as Help › Safe Mode's Enable on Re-launch does; it does not load, unload or uninstall anything +now, and `plugin.install` of a disabled plug-in turns it on. Turning a plug-in on by `id` or by installing it also turns +on the file it is stored in, which names it when it stopped a launch while loading; when the new version can't be saved +over that file, the plug-in stays disabled. `plugin.list` reports `safeMode` and `disabled`, and +`plugin.reload` skips disabled plug-ins. A change to the disabled list is saved on top of the list the file holds at +that moment, so a change another window saved to the same preferences meanwhile is kept. When the list file can't be +read (it is too large) or holds another format or version, it is left as it is; a file that isn't JSON is replaced. +`plugin.setEnabled` returns `saved: false` when its change isn't in the plug-in folder: the session has none (a server +confined with `--automation-read-root` or `--automation-write-root`), the list there can't be read or written, or it +already holds 1,000 plug-ins, as another window may have saved it. The +change stays in the process and is saved with the next change that saves, and the app shows a status message. When +Disable and Continue can't save the plug-in it turns off, this launch skips it and the next launch asks again. ```json +{"name":"run_command","arguments":{"command":"plugin.safeMode","params":{}}} +{"name":"run_command","arguments":{"command":"plugin.setEnabled","params":{"id":"org.example.wobble","enabled":true}}} {"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"}}} +{"name":"run_command","arguments":{"command":"plugin.org.example.tidy.grow","params":{"by":3}}} +{"name":"run_command","arguments":{"command":"plugin.install","params":{"path":"/plugins/tiles-2.wasm","replace":true}}} +{"name":"run_command","arguments":{"command":"plugin.clearCache","params":{"id":"org.example.tiles"}}} ``` ## Colour adjustments @@ -6375,6 +7072,18 @@ Object → Envelope Distort wraps the selection in a live envelope: `object.enve arcLower, arcUpper, arch, bulge, shellLower, shellUpper, flag, wave, fish, rise, fisheye, inflate, squeeze or twist; `bend`, `h` and `v` in % from -100 to 100; `horizontal` or `orientation`), `object.envelope.makeWithMesh` (`rows`, `cols`: 1–50) and `object.envelope.makeWithTopObject` (the topmost selected path becomes the envelope). +A top object's outline is split into four sides at four corners. Each corner is the anchor nearest a corner of the +outline's bounding box among the anchors within 47.5% of the box's width and height from that box corner that count as +corners: an anchor with a handle 0.01 pt long or shorter, or one whose tangent unit vectors differ by 0.01 or more in +x or y and turn out of the outline. In this test a straight segment counts as having its handles a third of the way +along it, so an anchor between two straight segments longer than 0.03 pt is a corner only when it turns out of the +outline. On a tie, the anchor met first along the outline in the order it was drawn wins. +Without such an anchor, the corner is the outline's point nearest the box corner, moved onto the anchor at the end of +its segment when within 0.5% of the segment's parameter. Each piece of a side between anchors and corners takes a +share of the content's width or height in proportion to the length of its control polygon, spread along the piece by +its curve parameter, so content spreads evenly along a segment without handles. An envelope opened from an `.ai` file +keeps the corners the file saves, and they stay on the same anchors when the envelope is moved, rotated or +reflected. These are VectorCraft's own rules, and existing top-object envelopes redraw by them. `object.envelope.info` reads the selected envelope's settings (`type`, the warp's or mesh's values, `fidelity` and the options, `editing`), or with no envelope selected the options new envelopes get. With an envelope selected, `object.envelope.resetWithWarp` and `object.envelope.resetWithMesh` (`rows`, `cols`, `maintainShape`: true keeps @@ -6474,7 +7183,8 @@ bounding-box handles scale it in plane space (`perspective.transform`; Shift kee centre), and `press_key` `5` during a drag switches the move to perpendicular to the plane (press again to switch back). Holding Shift during an in-plane move constrains each object's displacement to an axis or 45° diagonal of its own plane. `perspective.move` accepts `constrain: true` (default false) and adjusts Snap to Grid along the constrained -direction on the first object's plane and depth. +direction on the first object's plane and depth. With Snap to Grid, every object on the first object's plane moves +by the same plane offset, whatever its depth, and the edge of their joint bounds nearer a gridline lands on it. `press_key` Escape during a move, a resize or a plane widget drag cancels it and puts the objects and the grid back. The arrow keys nudge a selection in perspective by the keyboard increment (`perspective.nudge`; Shift ×10, Alt copies). Object › Transform › Transform Again repeats the last perspective move or scale in plane space. @@ -6742,6 +7452,40 @@ the entry names and decodes the codes that VectorCraft's default shortcuts agree {"name":"run_command","arguments":{"command":"importMatches.set","params":{"matches":[{"kind":"name","name":"Group","target":"object.group"},{"kind":"key","name":"901","target":"G"}]}}} ``` +## Action sets from other apps + +`actionSet.read {path? | data? | dataBase64?, name?}` reads an action set another app saved as text (an `.aia` file +that starts with `/version`) without changing anything. Give exactly one of `path`, `data` and `dataBase64`. It returns +the set's name (`set`, else the file's name, else empty), its `version`, and its `actions`, each with its `name` and +`steps`. A step lists its `name` (the file's `/internalName`), its `label` (the `/localizedName` it is shown with, or +null when it has none or it is blank), `on` (false when the file sets `/isOn 0`), `params` (how many recorded values it +has) and its `line`. `unread` lists the tokens the reader skipped, with their lines. The reader stops at 8 MiB, 16 open +blocks, 1,000 actions, 10,000 steps in an action and 100,000 steps in the set, and cuts names at 1,024 bytes. It sets +`truncated` when it reaches one of these limits, when the text ends inside a block or a value or after a field's name, +when `/actionCount`, `/eventCount` or a step's `/parameterCount` differs from the actions, steps or recorded values read +(a negative count, one too large for a number and one that is not a number always differ), or when a hexadecimal name, +label or recorded `/value` is not as many bytes as its count says. Text that does not start with `/version` and a +number is an error. + +`ui.actionSet.load` (the Actions panel menu's Load Actions…) reads such a file too, from `path`, a picked file or +`data`, and adds its set after the other sets with all its actions. The set's name is the file's `/name`, else the +file's name, else `Actions`. A step with a label and no recorded values comes in as the VectorCraft command with that +label, in English or in the interface language, in any case and without a final `…`, when exactly one menu command has +that label and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with +no values and opens no dialog (Group, Ungroup). A submenu's items, file and document commands, Paste, Undo, Redo and +add-on commands are never matched. The step runs with no params, and a step that is off in the file is excluded from +playback. Other steps are left out, including every step with no label and every step recorded with values (recorded +menu items too). The reply lists the first 1,000 in `skipped` (`{action, step}`, the step by its label, else its name), +`leftOut` counts them, and the status bar says how many were left out. An action with no name is called `Action N`, by +its place in the file. Function keys and colors are not read. A file marked `truncated` is refused, and no set is added. +Text that starts `/version` and is not an action set gives "not an action set file". Tokens in `unread` do not stop a +load. + +```json +{"name":"run_command","arguments":{"command":"actionSet.read","params":{"path":"/Users/me/Prep.aia"}}} +{"name":"run_command","arguments":{"command":"ui.actionSet.load","params":{"path":"/Users/me/Prep.aia"}}} +``` + ## Artwork Cleanup add-on Artwork Cleanup is a built-in add-on: its commands are `addon.cleanup.*`, and in the desktop app it lives in Object › diff --git a/docs/plugins.md b/docs/plugins.md index 27a7b0f91..890183c7f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -5,16 +5,20 @@ 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 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. +- **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`). @@ -23,12 +27,13 @@ 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, api, permissions, hash}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `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?, commands?, size, source, api, paintOutput?, permissions, hash, cacheBytes}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `commands` lists its [commands](#commands); `paintOutput: true` marks a [painted live effect](#painted-live-effects); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256; `cacheBytes` is the size of its [cache](#settings-and-cache)) | | `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. | -| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when `plugin.install` needs `grant`). | -| `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder. | +| `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`; an `ids` that is not a list of object ids is an error). 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`, `"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 module with other bytes than the plug-in installed with its id, or, with none installed, one whose id has [settings and a cache](#settings-and-cache) in the `Plug-ins` folder, installs only with `replace: true`, once the user agreed; it takes that plug-in's place and gets its settings and cache. The same module installs again without `replace`, and `replace: false` refuses a module whose id is installed. A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, unless a grant is already recorded for a module with the same SHA-256. A request's `grant` and `allowHeavy` are refused except in a session started with `vectorcraft-cli --allow-plugin-grants`. With `hash`, a module whose SHA-256 differs is refused. | +| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when installing it needs a person's approval) and `needsReplace` (true when `plugin.install` needs `replace: true`). | +| `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder with its [settings and cache](#settings-and-cache). | +| `plugin.clearCache` | `{"id"?}` | Deletes every entry in the [cache](#settings-and-cache) of plug-in `id`, or of every installed plug-in; settings stay. Returns `{"cleared": [id], "bytesFreed"}`. | | `plugin.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped, and so are a heavy filter and a plug-in whose permissions the user hasn't granted (nobody was asked). | Live effects are applied like any effect: `effect.apply {"effect": "plugin.<id>", "params": {…}}`, edited with @@ -38,18 +43,69 @@ An exporter adds a write-only format, `plugin.<id>`, which `document.formats` li `document.export {"format": "plugin.<id>", "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.<id>`, which `document.formats` lists with its `plugin` id. `openExtensions` +holds the extensions File › Open opens with the importer (the lists below describe which are left out). `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.<id>", "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, +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, and a file only an importer reads opens as a new document when it is dropped on the canvas. 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 of numbers and colors, labelled, explained and sorted into sections as its manifest says) with a live preview; without -parameters it runs at once. Parameters too many for the window's height scroll inside the dialog, which keeps its +parameters it runs at once. Edit › Keyboard Shortcuts lists each installed filter under Object › Plug-ins, where +it can be given a shortcut (`plugin:<id>`); the shortcut stays saved while the plug-in is removed and applies again +when it is reinstalled. 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 (other open dialogs, such as New +from Template and Load Styles, list only the files the built-in formats read). These lists leave out an importer's +extensions that a built-in format reads, and those File › Open installs, imports or loads as something else (`.wasm`, +`.kys`, `.jsx`, presets, libraries and swatch or graphic style libraries such as `.ase`). File › Open does not give +those files to the importer, and Open With does. **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. On the +desktop the importer runs off the UI thread, and a run that takes a while shows a progress window with Cancel, which +stops it. 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. Open Recent opens a file the way +File › Open does: it does not remember the importer or the parameters given to Open With. A built-in format reads the +file if one can, and otherwise the first importer by id that lists its extension reads it 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)). +### Commands + +A filter or a live effect may declare commands of its own in its manifest (`commands`, [below](#manifest)). Each +becomes the command `plugin.<id>.<command>` while the plug-in is installed, and is gone once the plug-in is removed or +replaced by a module without it. + +- **Menus.** A plug-in with commands is a submenu of **Object › Plug-ins** (a live effect: **Effect › Plug-ins**) + named after the plug-in. The submenu holds the plug-in's own item, then its commands. A command with parameters has + an ellipsis and opens a dialog for them, with a live preview, as a filter's own item does. A manifest cannot put a + command in another menu: a `menu` key in a command is ignored. +- **Keyboard Shortcuts.** Edit › Keyboard Shortcuts lists each command under its submenu, and the user can give it a + shortcut. A manifest cannot give a command a shortcut. +- **Actions and agents.** A command run is recorded in an action like any other command and plays back with its + parameters. `run`, MCP and `command.batch` call it as `plugin.<id>.<command>` with `{"ids"?: [...], "params"?: {…}}` + (its parameters may also be top-level keys). MCP's `command_list` and `list_commands` list it, and `plugin.list` + lists a plug-in's commands as `commands: [{id, command, label, params, paramsSchema}]`. +- **Runs.** The parameters are checked against the command's declarations before the plug-in runs. The plug-in runs + through `vc_run` on the selection (or `ids`) as a filter run does, with `_context.mode` `"command"` and + `_context.command` set to the command's id ([Parameters](#parameters)), under the plug-in's + [permissions](#permissions): a live effect with commands may also hold `doc.write` and `state`, which only its + commands use. `ids` left out or null means the selection; an `ids` that is not a list of object ids is an error. One run + is one undo step named after the command. A run that traps, runs out of its instruction budget or fails leaves the + document and the undo history as they were, and the error starts with the plug-in's name and the command's label + (`“Cmd Test” › Grow: …`). + **Preference folder.** Edit › Preferences › Plug-ins & Scratch Disks › *Additional Plug-ins Folder*: when set, every `*.wasm` in that folder (not recursive) is installed at startup and whenever the preference changes (desktop builds; the web build installs from bytes). On the desktop, Choose… beside the field picks the folder; the dialog refuses a @@ -67,30 +123,33 @@ The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssem | Limit | Default | On violation | |---|---|---| -| Host imports | none at API level 1; at level 2 only the three [host imports](#host-imports-api-level-2): no WASI, file system, network, clock or randomness | module rejected at install, naming the import | +| Host imports | none at API level 1; at level 2 only the [host imports](#host-imports-api-level-2): no WASI, file system, network, clock or randomness | module rejected at install, naming the import | | 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. - **Installing asks first.** Install Plug-in… and File › Open show what the filter asks for, and install it once the - user clicks OK; `plugin.install` refuses it without `allowHeavy: true`, which an agent passes once the user agreed - (`plugin.inspect` tells it beforehand). A filter kept in the `Plug-ins` folder was agreed to and is installed again + user clicks OK (see [Permissions](#permissions) for who can). `plugin.install` refuses it without `allowHeavy: true`, + and refuses `allowHeavy` from a request except in a session started with `vectorcraft-cli --allow-plugin-grants` + (`plugin.inspect` shows the budget beforehand). A filter kept in the `Plug-ins` folder was agreed to and is installed again at startup. The Additional Plug-ins Folder skips heavy filters with a note in its report, since loading a folder asks nobody. - **It runs in the background.** Its dialog has no Preview. OK (or the menu item, without parameters) runs it on a @@ -110,48 +169,110 @@ capability model proposed for PhotoCraft plug-ins | Permission | Kinds | What it allows | |---|---|---| -| `doc.read` | all | Read the objects it is given. | -| `doc.write` | filter | Change, add and delete objects in the document. | -| `state` | filter | Keep data of its own with the document (`documentData`). | +| `doc.read` | filter, effect, exporter | Read the objects it is given. | +| `doc.write` | filter, effect with `commands` | Change, add and delete objects in the document. | +| `state` | filter, effect with `commands` | 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. | | `progress` | all | Show its progress, see Cancel and write log lines through the [host imports](#host-imports-api-level-2). Needs `api` 2. | +| `settings` | all | Keep settings of its own between runs and sessions ([settings and cache](#settings-and-cache)). Needs `api` 2. | +| `cache` | all | Keep a cache of its own, which the user can clear ([settings and cache](#settings-and-cache)). Needs `api` 2. | - 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. - Any kind may add `progress`, which no kind implies. An unknown name, a name listed twice, a name that doesn't apply - to the kind, more than 16 names, `progress` with an `api` below 2, or one of the names kept for a later plug-in API + `doc.read`, `doc.write` and `state`; an effect `doc.read` and `effect.render`, and also `doc.write` and `state` + when it declares [commands](#commands); an exporter `doc.read` and `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 (`doc.read` and + `effect.render`; `doc.read` and `export.write`; `import.read`). An effect with commands may add `doc.write` and + `state`, which only its commands use. A filter may list any of its three. + Any kind may add `progress`, `settings` and `cache`, which no kind implies. An unknown name, a name listed twice, a + name that doesn't apply to the kind, more than 16 names, `progress`, `settings` or `cache` with an `api` below 2, or + one of the names kept for a later plug-in API (`ui.panel`, `ui.tool`) is refused when the module is installed, and the error names it. - **Installing asks first.** Install Plug-in… and File › Open show the plug-in's name, id, version, the SHA-256 of the file and each permission in plain words, and install it once the user clicks OK, if the file still has the SHA-256 shown; Cancel installs nothing. The names shown have control and bidirectional characters escaped (`\u{202E}`) and are cut to 64 characters. - `plugin.install` refuses the module, naming the permissions it needs, unless `grant` lists them all, which an agent - passes once the user agreed (`plugin.inspect`'s `needsApproval` tells it beforehand), with `hash` set to the - `plugin.inspect` entry's `hash` so that the module installed is the one the user saw. + Only a person can press its OK: `ui.dialog.confirm`, MCP's `answer_dialog` and injected input (`ui.key`, `ui.click`) + are refused and the dialog stays open. Enter confirms it only when OK has the keyboard focus. `ui.dialog.set` can't + change what it installs. +- `plugin.install` refuses the module, naming the permissions it needs, unless this module already has a recorded grant + (`plugin.inspect`'s `needsApproval` tells it beforehand). A request's `grant` is refused too: `plugin.install` takes + it only with the approval the dialog gives. A `vectorcraft-cli mcp` or `run` started with `--allow-plugin-grants` + takes `grant` and `allowHeavy` from any request; only the person starting the process can set that flag, and it + doesn't apply to a running app. `mcp` refuses the flag together with `--automation-read-root` or + `--automation-write-root`: a server confined to folders has no plug-in store, so it reads no `grants.json`, and a + module that lists permissions or asks for a heavy budget does not install there. Without the flag, MCP in headless + mode and the CLI install a module that lists permissions only when its hash is recorded in the shared `grants.json`. + A person's approval in the desktop app records it, and so does a `grant` accepted under `--allow-plugin-grants`. A + recorded grant counts as approved in later sessions without the flag and in the desktop app. With + `VECTORCRAFT_NO_PREFS` set there is no store, and a `grant` accepted under the flag lasts for that session only. + With `--allow-plugin-grants`, pass `hash` set to the `plugin.inspect` entry's `hash` so that the module installed is + the one the user saw. +- **Replacing asks first.** When the file is another module than the plug-in installed with its id, or, with none + installed, its id has settings and a cache in the `Plug-ins` folder, Install Plug-in… and File › Open show its id + and SHA-256 and ask whether to install it in place of that plug-in, which gives it the settings and cache kept for + the id. When the same module also needs its permissions or a heavy budget approved, the one approval dialog asks + about all of them, and only a person can press its OK. `plugin.install` refuses such a module, naming it, unless + `replace` is `true`, which an agent passes once the user agreed (`plugin.inspect`'s `needsReplace` tells it + beforehand). The Additional Plug-ins Folder and `plugin.reload` do not ask. - The grant is recorded with the plug-in's id, the module's hash and the permissions, in `grants.json` in the - `Plug-ins` folder. Installing a module with the same id that lists no new permission needs no new approval; one that - lists more asks again. `plugin.remove` forgets the grant. An entry of `grants.json` that this version can't use (a - permission it doesn't know, an invalid id) is skipped and stays in the file until its plug-in is granted again or - removed. A `grants.json` that is not a JSON list is never overwritten, and the file is never written larger than + `Plug-ins` folder, and covers only that module. Installing the same bytes again needs no new approval. A module with + other bytes asks again, even when it lists the same permissions, and does not get the id's settings and cache until + it is approved. Until then, the installed module and its grant stay as they are; once it is approved, the grant + records the new hash. When the new module or its grant can't be written to the `Plug-ins` folder, the folder keeps + the old module and its grant, and the old module loads at the next start. If the write that puts the old module back + fails as well, the folder holds the new module with the old grant, the grant does not cover that module, and the + plug-in is skipped at the next start until it is approved again. A grant whose hash is not 64 lower-case + hexadecimal digits covers nothing. `plugin.remove` forgets the grant, and so does a successful `plugin.install` of a + module that lists no permissions under that id, so the earlier bytes need a new approval. When `plugin.remove` can't + remove the grant from `grants.json`, it returns an error, the plug-in stays installed, and a second `plugin.remove` + tries again. A refused install leaves the grant as it is. When an install of a module that lists no permissions can't + write the module to the `Plug-ins` folder, or can't remove the grant from `grants.json`, the folder keeps the module + it held and `grants.json` keeps the grant. The new module runs until the session ends. When the grant can't be + removed and the module already stored can't be read back or written back, the new module stays in the folder, and + the grant does not count for the rest of the session. The grant also does not count when the folder held no module + for that id. A + `grants.json` larger than 1 MiB, or one that is not a JSON list, grants nothing: the new module stays in the folder, + and `plugin.remove` removes the plug-in. A `grants.json` that can't be opened or read counts as a grant that can't be + removed. When VectorCraft loads the `Plug-ins` folder, it forgets any grant for an id whose stored module lists no + permissions. When it can't remove that grant from `grants.json`, or can't read the file, the grant does not count for + the rest of the session, or until the plug-in is granted again, including for the next load, which tries the removal + again. In Safe Mode, and for + a stored module that the user disabled, + VectorCraft does not read the module, and `grants.json` keeps the grant for its id. The Additional Plug-ins Folder + and `plugin.reload` do not change the `Plug-ins` folder: when they leave a module that lists no permissions under an + id, the process forgets its own grant for that id, and a grant kept in `grants.json` stays with the stored module it + covers. An entry of `grants.json` that this version can't use (a permission it doesn't know, an invalid id) is + skipped and stays in the file until its plug-in is granted again, replaced by a module that lists no permissions, or + removed. When such an entry can't be removed from the file, `plugin.install` and `plugin.remove` treat it as a grant + that can't be removed. A `grants.json` that is not a JSON list is never overwritten, and the file is never written larger than 1 MiB, the most that is read. When a `Plug-ins` folder is in use, only the grants kept in it count for `plugin.install`, `plugin.inspect`, `plugin.reload` and the Additional Plug-ins Folder. At startup, - a stored plug-in whose permissions are not granted is skipped. The Additional Plug-ins Folder and `plugin.reload` - install only plug-ins whose permissions were granted, and report the others. -- The host enforces the grant on a filter run: without `doc.read` the filter is given no objects; without `state` its + a stored plug-in is skipped when no grant covers its bytes and the permissions it lists. The Additional Plug-ins + Folder and `plugin.reload` install only plug-ins that such a grant covers, and report the others. A module without a + `permissions` list, or with an empty one, needs no grant. +- The host enforces the grant on a filter run and a command 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) or `2`, which adds the [host - imports](#host-imports-api-level-2). A higher one is refused. `plugin.list` and `plugin.info` give it as `api`. + imports](#host-imports-api-level-2), a filter's `inputs` ([reading type, images and + symbols](#reading-type-images-and-symbols)) and a live effect's [`paintOutput`](#painted-live-effects). A higher one + is refused. `plugin.list` and `plugin.info` give it as `api`. `inputs` is not a permission: the objects it asks for + are read under `doc.read`, which a filter that lists `inputs` must list in `permissions`, so installing it asks the + user to grant them. 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 +Every run gets a **fresh instance**, so no state survives between calls except what a plug-in writes to its +[settings and cache](#settings-and-cache). Traps, exhausted budgets, malformed modules, bad manifests, out-of-range pointers, negative return codes and invalid output all become errors, and the document is left untouched: a filter's output is checked completely before one edit applies it. A live effect that -fails draws the object's geometry unchanged; `plugin.info` reports why (`lastError`). +fails draws the object's geometry unchanged; `plugin.info` reports why (`lastError`). A run that succeeds clears the +error of a failed run. A check that finds every swatch and pattern a painted result names clears only the error of a +refused swatch or pattern. wasmi is built with its *portable* (loop) dispatcher: the default tail-call dispatcher relies on LLVM sibling-call optimisation, and when that doesn't happen a long-running module overflows the host stack, which aborts @@ -162,8 +283,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, and a paint linked to a global swatch is drawn in that swatch's current color at its own tint. ## ABI v1 @@ -182,9 +305,10 @@ module's memory. ### Host imports (API level 2) -A plug-in whose manifest gives `"api": 2` and lists the `progress` [permission](#permissions) may import these +A plug-in whose manifest gives `"api": 2` and lists the [permission](#permissions) an import needs may import these functions from module `env` (the module a Rust or C toolchain gives `extern` functions by default). `vc_abi_version` -still returns `1`. +still returns `1`. `vc_progress`, `vc_cancelled` and `vc_log` need `progress`; the `vc_settings_*` imports need +`settings` and the `vc_cache_*` imports need `cache` ([settings and cache](#settings-and-cache)). | Import | Signature | Meaning | |---|---|---| @@ -193,8 +317,8 @@ still returns `1`. | `vc_log` | `(level, ptr, len) -> i32` | Writes the UTF-8 text at `ptr` as a log line of `level` (0 error, 1 warning, 2 information, 3 debug). Returns `0` when written, `1` when the run's log budget is spent and the line was dropped, `-1` when the text doesn't lie inside the module's memory, `-2` when it is not UTF-8, `-3` for another level. | - The host checks the imports when the module is installed: an import it doesn't offer, one with another signature, - and one the manifest doesn't allow (an `api` below 2, or no `progress` permission) refuse the module, and the error - names the import. + and one the manifest doesn't allow (an `api` below 2, or without the import's permission) refuse the module, and the + error names the import and the permission. - A filter's or an exporter's progress is shown as a share: a heavy filter's progress window and the export's progress window draw a bar with a percentage, and Cancel there makes `vc_cancelled` return `1`. A plug-in that checks it can stop early, and the host discards what it returns after the Cancel or after its time budget ran out; one that @@ -208,6 +332,63 @@ still returns `1`. text, and the host reads at most 1 MiB of log text in a run; past any of these, one "log truncated" line is written and the rest are dropped. +### Settings and cache + +A plug-in with the `settings` permission keeps named values between runs and sessions, and one with `cache` keeps +named entries that the user can clear (#2191). It names each value by a key and never by a path. Each import takes +the key as `(key, key_len)`, the UTF-8 bytes at `key` in its memory: + +| Import | Signature | Meaning | +|---|---|---| +| `vc_settings_get` | `(key, key_len, out, out_cap) -> i32` | The setting's length in bytes. When it fits in the `out_cap` bytes at `out`, its bytes are copied there; otherwise nothing is copied. | +| `vc_settings_set` | `(key, key_len, value, value_len) -> i32` | Sets the setting to the `value_len` bytes at `value`. Returns `0`. | +| `vc_settings_delete` | `(key, key_len) -> i32` | Removes the setting. Returns `0`. | +| `vc_cache_read` | `(key, key_len, out, out_cap) -> i32` | As `vc_settings_get`, for the cache entry. | +| `vc_cache_write` | `(key, key_len, value, value_len) -> i32` | As `vc_settings_set`, for the cache entry. | +| `vc_cache_clear` | `(key, key_len) -> i32` | Removes the cache entry; with `key_len` 0, every entry. Returns `0`. | + +A call that fails returns `-1` when the key has no value, `-2` when a key, value or buffer doesn't lie inside the +module's memory, `-3` when the key is not a valid key, `-4` when the write would pass a cap (nothing changes), `-5` +when there is nowhere to keep anything, `-6` when reading or writing the file failed, and `-7` when the run has used +its 10,000 storage calls or its 1 GiB of storage reads and writes. Each call counts the bytes it reads and writes. A +settings call counts the whole settings file when it reads the file, and a set or delete also counts the whole file +when it writes the file; a cache call counts the entry it reads or writes. A read counts them even when the value +doesn't fit and nothing is copied. A call that would pass the 1 GiB returns `-7` before it reads or writes anything. + +The app keeps the settings in memory after the first call and reads `settings.json` again only when the file's size +or modified time differs from the last read or write. A get answered from memory reads no file and counts no bytes. A +set to the value the key already has writes nothing, and neither does a delete of a key that has no value. A delete +of the last setting removes the file and counts no written bytes. Every other successful set or delete writes the +file before the call returns. A cache call reads or writes the whole entry in memory in one step, up to 64 MiB, and +the run waits for it. +A live effect runs while the art draws, so keep the entries it uses small. + +- A key is 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-`, and is not a Windows device name (`CON`, `PRN`, + `AUX`, `NUL`, `COM0` to `COM9`, `LPT0` to `LPT9`, in any case). Keys differ in case: `tile` and `Tile` are two. +- These caps are VectorCraft's own values: at most 256 settings of at most 64 KiB each, in a settings file of at most + 1 MiB; cache entries of at most 64 MiB each, at most 4,096 entries and 512 MiB per plug-in. The cache has no + automatic eviction: a write past a cap fails with `-4`, and the plug-in or the user clears entries. A cache write + also fails with `-4` when the `cache` folder holds more than 8,192 items; clearing the cache removes every file in + it. +- The desktop app and `vectorcraft-cli` keep them in a folder named from the id under `storage/` in the `Plug-ins` + folder (ids that differ only in case get two folders): `settings.json` and a + `cache` folder with one file per entry, named by the host. A settings file that is too large or doesn't parse is + moved to `settings.unreadable.json`, replacing an earlier one there, and the settings start empty. A link in that + folder is refused (`-5`). Writes go to a temporary file that then replaces the old one; a temporary file a failed + write leaves in the `cache` folder does not count toward the caps, and clearing the cache removes it. +- A plug-in reaches only the storage of its own id. Replacing it with another module of the same id + (`plugin.install` with `replace: true`, after the user agreed) keeps the storage; `plugin.remove` deletes it, and a run of the plug-in still going gets `-5` from every storage call after that. + `plugin.remove` first renames the folder (adding `.removed` to its name). When that fails, `plugin.remove` fails + and the plug-in stays installed with its storage as it was. Files that can't be deleted after the rename stay in the + `.removed` folder, where no plug-in reaches them. A later `plugin.remove` tries again to delete them when a plug-in + with that id is installed or a storage folder for the id exists, and that deletion can fail too. For an id that + isn't installed and has only a `.removed` folder, `plugin.remove` does not delete the folder and fails as not found. + `plugin.remove` with the id of a plug-in that isn't installed deletes the storage kept for that id, when there is + any. + `plugin.clearCache` empties the cache and leaves the settings, and `plugin.list` and + `plugin.info` give the cache's size as `cacheBytes`. +- Without a `Plug-ins` folder (the web build, and embedders that attach none) the storage calls return `-5`. + ### Manifest ```json @@ -230,16 +411,32 @@ still returns `1`. - `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.<id>`. -- `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. +- `inputs` (filters at `api` 2 only): the kinds of object besides paths and compound paths the filter reads, any of + `"type"`, `"image"` and `"symbolInstance"`, each at most once (`"inputs": ["type", "image"]`). Left out or `[]`, the + filter gets paths and compound paths alone, as before. An unknown, repeated or wrongly typed entry, `inputs` on + another kind, below `api` 2, or without a `permissions` list that holds `doc.read`, is refused when the plug-in is + installed, and so is `api` 2 on hosts from before it. +- `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 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. +- `commands` (filters and live effects; not heavy filters): up to 32 commands of the plug-in's own + ([Commands](#commands)), each `{"id": "grow", "label": "Grow", "params"?: {…}}`. An `id` is 1–32 characters from + `a-z 0-9 _` that starts with a letter, and appears once. A `label` is 1–64 printable characters; menus show it with + control and bidirectional characters escaped. `params` are declared as the plug-in's own `params` are. A malformed + entry is refused when the module is installed, and the error names the command by its id (or its place, `[0]`). - `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 @@ -276,18 +473,22 @@ parameters uses `label`, `unit`, `help` and `visibleWhen`, and lists the paramet `input` is UTF-8 JSON: `{"objects": [...]}`. An object filter gets the selected paths and compound paths in paint order (bottom first): the selected ones and those inside selected groups and layers; hidden, locked, guide (compound -guides too) and clipping paths are left out, and so are other kinds of object (type, images, symbols, live blends…), which a filter never -touches. With nothing selected the list is empty (a generator then adds its own objects). A live effect gets one +guides too) and clipping paths are left out, and so are other kinds of object (type, images, symbols, live blends…), +which a filter never touches unless its manifest lists them in `inputs` ([reading type, images and +symbols](#reading-type-images-and-symbols)). 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, +An **exporter** gets something else: the native `.vectorcraft` document JSON without indentation, optional preview/PDF +renditions or save-preference metadata (the format is documented in [`crates/format/src/lib.rs`](../crates/format/src/lib.rs)). That is every layer, object, appearance and effect record, artboard, swatch and symbol, so an exporter reads whatever the document holds; it checks `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", @@ -323,6 +524,70 @@ type and `blob` but without their data, which follows the JSON in a saved file; `plugin.<id>` for a live-effect plug-in), `params` and `visible`. Its fills' and strokes' own effects aren't listed. - `bounds`: `[x0, y0, x1, y1]`, the geometric bounds (read only). +### Reading type, images and symbols + +A filter whose manifest lists `inputs` also gets the selected objects of those kinds, in paint order among the paths +and under the same rules (hidden and locked ones, objects that clip a clip group, alone or inside a group that clips +it, and anything inside a symbol, a placed document or a live object are left out). They are never turned into +outlines, rectangles or the symbol's art. Each has `id`, `type`, `name` (when it has one), `bounds` (`null` until the +object has been laid out or drawn) and +`transform`, `[a, b, c, d, e, f]` mapping its own space (text space, pixel space, the symbol's art) into the document. +In this example the character and paragraph styles are cut short; a filter gets every attribute: + +```json +{"objects": [ + {"id": 17, "type": "type", "bounds": [10, 38, 52, 52], "transform": [1, 0, 0, 1, 10, 50], + "text": {"kind": "point", "vertical": false, + "runs": [{"text": "Hello\nthere", "style": {"font_family": "Source Sans 3", "size": 12}}], + "paragraphs": [{"justify": "Left"}, {"justify": "Center"}]}, + "truncated": false}, + {"id": 18, "type": "image", "bounds": [0, 0, 4, 2], "transform": [2, 0, 0, 2, 0, 0], "pixelSize": [2, 1], + "colorMode": "rgb", "linked": false, "pixelsOmitted": false, "pixelsBase64": "AAAegGQAHoA="}, + {"id": 19, "type": "symbolInstance", "bounds": [100, 0, 110, 10], "transform": [1, 0, 0, 1, 100, 0], + "symbolId": "Star", "overrides": {}} +]} +``` + +- Type (`"type"`): `text.kind` is `"point"`, `"area"` or `"path"`; `runs` are its text runs with their character + attributes as the native file stores them (`inline` when a run is an inline graphic), and `paragraphs` holds one + paragraph style per paragraph (the paragraphs are split at `\n`). No font data or glyph outlines are given. Type + whose runs hold more than 256 KiB of UTF-8 text, or more than 4,096 runs or paragraphs, comes with `truncated: + true` and empty `runs` and `paragraphs`. The runs and paragraphs of all the type in one input take at most 16 MiB + of JSON: type whose runs and paragraphs don't fit what the type before it in paint order left comes with + `truncated: true` too. +- An image (`"image"`): `pixelSize` is its width and height in pixels; `colorMode` is `"rgb"`, `"cmyk"`, `"gray"` or + `"unknown"`, read from the image itself. An embedded image whose pixels take at most 1 MiB in base64 (443 × 443 + pixels fit) has them in `pixelsBase64`: straight RGBA, 8 bits a channel, row by row from the top, and + `pixelsOmitted: false`. A CMYK image's pixels are its inks through the working CMYK profile, as sRGB. The pixels of + all the images in one input take at most 16 MiB in base64, and an image the host starts to decode takes its size + from those 16 MiB even when decoding fails. A larger image, one whose pixels don't fit what the images before + it in paint order left, and a linked image (`linked: true`) have + `pixelsOmitted: true` and no pixels; the host checks an image's size before it decodes the image. An image of any + size also has `pixelsOmitted: true` when its data is missing from the document, when its width or height is 0, when + its data cannot be decoded (including a non-CMYK image whose decoder enforces the host's 64 MiB memory limit and + needs more, and a TIFF the host rejects as too large), or when the dimensions in its data differ from `pixelSize`. + A plug-in checks `pixelsOmitted` before it reads `pixelsBase64`, which is present only when `pixelsOmitted` is `false`. The host never + reads a linked image's file for a filter and never gives its path. +- A symbol instance (`"symbolInstance"`): `symbolId` is the name of the symbol it shows, with `missing: true` when the + document has no symbol of that name. `overrides` is `{}`. + +These objects are read only. The output must list each of them exactly once, as `{"id": 17}` alone or exactly as it +was given (numbers compare by value, so `1` and `1.0` are the same). A changed or added field, one left out, an object +listed twice or left out, and a new object with one of these types are refused, and the document and undo history +stay as they were: `objects[1] (id 17): read-only field 'text.runs[0].text' changed`, `objects (id 17): a read-only +object was left out`. The field is named with control and bidirectional characters escaped (`\u{202E}`), cut to 64 +characters. New objects listed after one of them go just above it. + +Every object in the input counts toward the half of the memory cap that the input and the parameters share, in paint +order, with its id, bounds, transform and name. Type whose runs and paragraphs don't fit what is left comes with +`truncated: true`, and an image whose pixels don't fit comes with `pixelsOmitted: true`. When the other fields of one +of these objects don't fit what is left, the run fails before the plug-in starts, naming the object and the bytes it +needs: `plug-in exceeded its memory budget (object 17 needs 4200 bytes of input, 1000 left)`. A path or compound path +that doesn't fit fails it with `plug-in exceeded its memory budget (the selection is too large to hand over)` before +the objects after it are written. An output lists at most 100,000 objects, so a run given more than 100,000 of these +objects fails with `plug-in exceeded its object budget (more than 100000 read-only objects)`. An echo of them counts +toward the 64 MiB output cap. + ### Parameters `params` points at `params_len` bytes of UTF-8 JSON: the validated parameters, plus `_context`: @@ -332,14 +597,17 @@ 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"`, `"import"` or `"command"` (a [command](#commands) run, whose +`command` is the command's id); `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 @@ -384,12 +652,63 @@ 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, as does a key that a paint, its color, a gradient stop, a gradient's placement or a freeform point does not + have, such as `opacity` on a solid paint. +- `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, raster effects, Pathfinder effects, compound shapes and Envelope Options › Distort Appearance use the pieces + as they are drawn. Outside outline mode, an image is also hit where the pieces of its own fills and strokes paint; a + path or compound path is hit-tested on its source path and appearance. On an object with no geometry effect of its + own, Pathfinder effects and compound shapes use the object's path for an effect on a fill or stroke, as they do for + every other effect on a fill or stroke; on an object that has one, they also use the pieces of a painted effect on a + fill or stroke. A 3D effect later in the stack uses the + object's input path when the pieces name a swatch or pattern. 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. Object › Expand of gradient and pattern + fills expands the fills of each piece, and leaves a piece with other paints as it is drawn. 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 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 @@ -418,8 +737,10 @@ 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, 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. +- Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, and + importers that place files (File › Place reads only the built-in formats) or make type, images, layers or + artboards. +- Live effects that change opacity, blending or effects (at `api` 2 one may [paint](#painted-live-effects) the pieces + it returns), and filters that change type, images or symbol instances (a filter at `api` 2 reads them, see + [`inputs`](#reading-type-images-and-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/releasing.md b/docs/releasing.md index b28ada6d6..64c177618 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -164,7 +164,7 @@ to the target triple, so host build scripts aren't affected. AppStream metainfo, the MIME type for VectorCraft documents, the icons and the licence files. **Document types.** The desktop entry's `MimeType` lists every format File › Open reads but PSD, -PSB, `.ps` PostScript, Targa, PCX, Pixar and JPEG 2000, so file managers list VectorCraft under Open With for them. +PSB, `.ps` PostScript, Targa, PCX, Pixar, JPEG 2000 and HEIC/HEIF, so file managers list VectorCraft under Open With for them. `ai.storyteller.vectorcraft.mime.xml` declares the types that shared-mime-info lacks: VectorCraft documents and templates, `.ait` templates and Affinity files (`application/x-affinity`). An `.af` file gets the Affinity type by its content, and `.afdesign` and `.afpub` files by their extension. diff --git a/docs/roadmap.md b/docs/roadmap.md index 6a243189e..d058715ab 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,9 +1,11 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence +Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow keys still require focus; modifiers, units, limits, mixed values and existing rounding stay in the shared stepping path. Headless regressions in `tests/numfields.rs` cover hovered unfocused fields, focused fields, trackpad accumulation and panel scroll exclusion. Scores and estimates unchanged. + Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. **PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. @@ -14,6 +16,8 @@ Group Selection keeps an already selected component selected during a drag; a co Creating a gradient from solid or empty paint uses the document's color model for its default stops, including drawing its vector with the Gradient tool. Explicit `keepModel: true` retains RGB defaults, and existing gradients keep their own colors. Scores and estimates are unchanged. +Window → Help Bar toggles the bottom tool-help strip independently of the status bar. Its visibility is saved with workspaces and UI preferences; Presentation Mode hides both bars. Scores and estimates are unchanged. + **Feature evidence last updated:** 2026-11-03. M1 selection: Enclosing Lasso selects whole objects enclosed by a freehand loop. It is an add-on @@ -36,11 +40,18 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps its paragraphs' tab stops, and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops from the left edge (#6147). Scores and estimates unchanged. | +| 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps auto leading at the paragraph's share, the paragraph's hyphenation options and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified as a wrapped line is (#6137); a user's `.ai` that opened from its PDF part now opens from its layers. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | +| 2026-11-08 | minor | Character Styles panel: a double-click on a user style's row opens its Character Style Options (`ui.charStyleOptions`); the dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | +| 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | +| 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. | +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-05 | minor | Effect dialogs: a colour swatch beside a hex colour field follows typed values, and a double-click opens the Color Picker over the dialog, whose OK fills the field (#5365); scores and estimates unchanged. | | 2026-11-05 | minor | Selection Tools add-on: Select Linked, Embedded, Missing and Modified select the images and placed documents with that link status (Object › Plug-ins › Selection Tools and the Links panel menu; Shift adds), and Select by Link Status… lists them by name, status and format to pick from, optionally zooming to them; what is hidden, locked, on a template layer or outside the isolated group is left out and the status bar says how many (#5526, a VectorCraft addition); scores and estimates unchanged (estimated). | | 2026-11-05 | minor | Pencil and Paintbrush: a new stroke with Keep selected off leaves nothing selected, the earlier selection included, while Keep selected on selects only the new path (#5752); scores and estimates unchanged. | | 2026-11-05 | minor | Layers panel: a layer whose Layer Options › Print is off shows its name italic, as the reference application shows non-printing layers, and keeps it while the row is dragged (#5739); scores and estimates unchanged. | +| 2026-11-04 | minor | M7: Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); estimates unchanged. | | 2026-11-04 | minor | M7: Find Font's Replace With Font lists family and style rows with a search, and the list and the document's font list scroll on their own (#4972); scores and estimates unchanged. | | 2026-11-04 | minor | Selection Tools add-on: Select by Artboards selects the objects overlapping, fully inside or fully outside several chosen artboards, from a dialog that lists, searches and multi-selects the artboards and fits one in the window on double-click (#5525, #5548, #5549, a VectorCraft addition); scores and estimates unchanged. | | 2026-11-04 | minor | M4: raster/PSD exports default to document resolution; CLI run/convert expose --ppi, matching the engine and MCP precedence. Scores and hours unchanged. | @@ -48,6 +59,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | 2026-11-04 | minor | New Document: the units chosen in Preset Details belong to the chosen preset; Recent and Saved cards keep the units their document or preset was made in, and the built-in tabs still follow the chosen units (#2078). Scores and estimates unchanged. | | 2026-11-03 | minor | M1: Enclosing Lasso provides whole-object enclosure selection as an add-on with no default shortcut or Basic toolbar slot; estimates unchanged. | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | +| 2026-11-03 | minor | Help Bar has its own Window menu toggle and workspace visibility setting. Scores and estimates unchanged. | | 2026-10-30 | minor | M4: G9: `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout) in its state block after its record, so its steps knock each other out as the page draws them; a user's file comes in 0.6% from its page instead of 1.3%. Scores and estimates unchanged. | | 2026-10-30 | minor | M4: File formats: `.ai`/EPS editing-data fidelity from an audit of a user's files: All Caps and Small Caps (#4708); placed files at their box's corner, kept where they draw closer to the page (#4664, #4723, #4742); pages cropped to the art, with `UserUnit` (#4658, #4727); gradient mesh corners with handle weights of 0 and the state written after a mesh (#4738, #4744, #4748); type on a path spaced as the file lays it out (#4743); synthetic regressions in `crates/eps`; scores and estimates unchanged. | | 2026-11-03 | minor | M7: Find Font replacement sources Document, Recent and System are shipped (#5217); estimates unchanged. | @@ -119,17 +131,15 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l |---|---|---|---| | M0 | Skeleton + vertical slice | ✅ done | — | | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | -| M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu | 12–18 | -| M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 | +| M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | -| M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default | 4–8 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof (#4936; the dialog, Auto Spell Check and the custom dictionary remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof (#4936; the dialog, Auto Spell Check and the custom dictionary remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof (#4936; the dialog, Auto Spell Check and the custom dictionary remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, a generated Basic Shapes library, User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | @@ -143,7 +153,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; broader scripting and batch work remains | 18–27 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 | -| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset <name>, UI.7), panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending | 18–28 | +| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset <name>, UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged | 18–28 | | — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 | | — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 PngSuite integrity and bounded fetching verified (#1729); real-world corpus/manual QA remain | 50–80 | | M14 follow-up | Color Picker layout (#2008) | ✅ Compact controls, centered header, stacked actions, aligned fields and screen sampling; narrow windows scroll vertically | Included above | @@ -180,6 +190,11 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-09 | minor | M4/G9: EPS import: the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. | +| 2026-11-08 | minor | M7: spell check engine, `text.spelling.check` and `text.spelling.suggest` on craft-proof (#4936); the dialog, Auto Spell Check and the custom dictionary remain; scores and estimates unchanged. | +| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. | +| 2026-11-09 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | +| 2026-11-07 | minor | M2: with the Pen or Curvature tool, Edit › Copy and Cut take the anchors and segments a Cmd/Ctrl-lent Direct Selection picked, not the whole path (#6123); estimates unchanged | | 2026-11-07 | minor | Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged. | | 2026-11-06 | minor | M2: a shape just drawn moves by its centre point (Show Center) and turns from the rotate zone just outside a bounding-box corner with its tool still active, as the Selection tool's gestures do (#5477); estimates 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 | @@ -192,8 +207,6 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-11-03 | minor | M7: Touch Type moves right-to-left characters along their line while keeping text editable; first-logical-character restriction retained. Estimates unchanged. | | 2026-10-30 | patch | M5: opaque solid-fill knockout paths avoid redundant erase/add layers in hard-edge frames and supersampled raster export; exact-pixel regression coverage retains analytical-AA rounding and translucent knockout behavior; scores and estimates unchanged | | 2026-11-03 | minor | M7: retain composition trimming in rotated character placement and rotation metadata; estimates unchanged. | -| 2026-10-30 | minor | M4: G9: `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout) in its state block after its record, so its steps knock each other out as the page draws them; a user's file comes in 0.6% from its page instead of 1.3%. Scores and estimates unchanged. | -| 2026-10-30 | minor | M4: File formats: `.ai`/EPS editing-data fidelity from an audit of a user's files: All Caps and Small Caps (#4708); placed files at their box's corner, kept where they draw closer to the page (#4664, #4723, #4742); pages cropped to the art, with `UserUnit` (#4658, #4727); gradient mesh corners with handle weights of 0 and the state written after a mesh (#4738, #4744, #4748); type on a path spaced as the file lays it out (#4743); synthetic regressions in `crates/eps`; scores and estimates unchanged. | | 2026-10-29 | minor | M6: Pathfinder: live compound shapes (#4522): the panel menu's Make, Release and Expand Compound Shape, Alt-clicking a Shape Mode, a selected member's Shape Mode and the Expand button act; scores unchanged | | 2026-10-26 | minor | M4/G9: EPS Save and Save As: an opened EPS saves back to its own path as EPS, keeping the chosen artboard's page frame and transparent margins and the complete editable VectorCraft document; Save's TIFF preview is capped at 1024 px (Export keeps 2048); partly read, preview-only or damaged-editing-data sources still need confirmation before they are overwritten; it does not write Illustrator's own editing data (#868), and G9's real-file import fidelity is unchanged; scores and estimates unchanged | | 2026-10-25 | minor | M10/G1: a selected Grid Repeat shows handles: the one below the grid adds or removes rows, the one right of it columns, the one past its bottom-right corner both, and the sliders between the first two columns and rows set the horizontal and vertical spacing; each drag is one undo step and Escape cancels it (#2691); estimates unchanged | @@ -212,9 +225,6 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. | | 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h). | | 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. | -| 2026-10-22 | minor | Artboard Options honors Scale Artwork with Artboard for preset, orientation and typed-size changes; both Fits keep artwork stationary; scores and estimates unchanged. | -| 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. | -| 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h). | | 2026-10-22 | minor | Font pickers list recently applied families first (#3515), using the existing recent-font limit; search, filters, previews and keyboard selection share the same rows. Scores and estimates unchanged. | | 2026-10-21 | minor | Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximation. Scores and estimates unchanged. | | 2026-10-21 | minor | M14: workspaces keep the layout changes made in them across switches and restarts until Reset <name> (UI.7); estimates unchanged | @@ -225,6 +235,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-21 | minor | Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog. Scores and estimates unchanged. | | 2026-10-21 | minor | Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls. Scores and estimates unchanged. | | 2026-10-21 | minor | Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls. Scores and estimates unchanged. | +| 2026-10-21 | minor | SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts. Scores and estimates unchanged. | | 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged | | 2026-10-20 | minor | Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; existing opening gestures retained; scores and hours unchanged. | | 2026-10-20 | minor | M12: consolidated three duplicate milestone rows into one, retaining distinct Image Trace and Graphs details and the quick-preset wording correction; status, scores and 6–10 h estimate unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1f769d370..ca6116c00 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,9 +1,13 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence +EPS embedded fonts, 2026-11-09 (#6023–#6067): the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. Evidence: `crates/eps/src/import/tests_composite.rs`, `tests_type42.rs`, `tests_cid.rs`, `tests_type1.rs`; details in [file-format-parity.md](file-format-parity.md). + +Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow keys still require focus; modifiers, units, limits, mixed values and existing rounding stay in the shared stepping path. Headless regressions in `tests/numfields.rs` cover hovered unfocused fields, focused fields, trackpad accumulation and panel scroll exclusion. A canvas-first/panel-second regression also checks that a field turn cannot start a canvas zoom glide when the pointer enters the canvas on the next frame. Scores and estimates unchanged. + Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. **PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. @@ -14,12 +18,18 @@ Group Selection keeps an already selected component selected during a drag; a co Ligature caret stops use a font's GDEF contour-point positions in horizontal and vertical text. They use unhinted outlines at the default variation location; invalid point indices use the existing evenly divided fallback. Scores and estimates are unchanged. +Spiral decay now applies after each full wind of four segments. A newly drawn spiral at 80% decay keeps 80% of its radius after one wind. Existing saved paths retain their geometry; newly drawn paths with the same settings differ from earlier VectorCraft builds. Scores and estimates are unchanged. + Creating a gradient from solid or empty paint uses the document's color model for its default stops, including drawing its vector with the Gradient tool. Explicit `keepModel: true` retains RGB defaults, and existing gradients keep their own colors. Scores and estimates are unchanged. -**Feature evidence last updated:** 2026-11-05. +**Feature evidence last updated:** 2026-11-07. + +Window → Help Bar toggles the bottom tool-help strip independently of the status bar. Its visibility is saved with workspaces and UI preferences; Presentation Mode hides both bars. Scores and estimates are unchanged. The shared assessment status line and revision history are current upstream; this dated section records the submission's feature evidence and revisions without rescoring upstream or claiming a new combined build or runtime measurement. +- File formats and Type core (#6147): `.ai`/EPS type from the text document keeps its paragraphs' tab stops, and right-aligned area type sets the text before a line's last tab at its stops, from the left edge, and the text after it against the right edge. Evidence: `ate::tests::a_paragraph_keeps_its_tab_stops`, `ate::tests::a_story_s_tab_stops_are_bounded`, `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`, `import_fuzz::text_frames_with_hostile_paragraph_attributes_never_panic`; scores and estimates unchanged +- File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading at the paragraph's share of each line's largest type, the paragraph's hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them; a line a soft return ends is justified as a wrapped line is. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently; scores and estimates unchanged - Current-main conflict reconciliation retains upstream and feature behavior; scores and estimates unchanged - Superseded 2026-10-20 behavior, retained as history: Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting uses the original target artboard and revision-scoped bounds caches (scores/hours unchanged) @@ -31,7 +41,18 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | File formats and Type core (#6147): `.ai`/EPS type keeps its paragraphs' tab stops (positions and alignments; leaders aren't read; at most 1,024 a paragraph and 2^20 a story), and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops; centred and justified lines are aligned as a whole as before; evidence added. Scores and estimates unchanged. | +| 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified; evidence added. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | +| 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | +| 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | +| 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. | +| 2026-11-07 | minor | Pen and Curvature tool with Cmd/Ctrl held: Edit › Copy and Cut take the anchors and segments the lent Direct Selection picked, not the whole path; with the Pen's own last anchor selected Copy still takes the whole path (#6123); scores and estimates unchanged | +| 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected (the path selected whole), a Shift-click on a corner widget picks that corner alone, as the first click of "click one widget, then Shift-click the others" does, instead of deselecting it and hiding its widget (#5188). The other corners' widgets then hide: a Shift-click on another corner's anchor adds that corner and shows its widget, and a Shift-click on a showing widget deselects its corner. Adding a corner by Shift-clicking its widget while it is hidden stays open (#1232). Test: `corners::tests::a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`. Scores and estimates unchanged. | +| 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected (the path selected whole), a Shift-click on a corner widget picks that corner alone, as the first click of "click one widget, then Shift-click the others" does, instead of deselecting it and hiding its widget (#5188). Once some corners are picked, Shift-click still adds or removes one. Test: `corners::tests::a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`. Scores and estimates unchanged. | +| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. | +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-07 | minor | The canvas keeps selection outlines, anchors and handles between frames, so an idle frame with a large selection redraws them as one mesh; scores and estimates unchanged | +| 2026-11-07 | minor | Stroke gradients along or across a variable-width stroke: where the stroke crosses or runs close by itself, a narrow part paints over a wide part only where the two overlap, instead of in a square as wide as the stroke gets anywhere (#5960). Each slice reaches as far as the stroke is wide there (for a dashed stroke, wherever a cap or dot can reach, found in one pass along the path however densely it is flattened). Tests: `stroke::tests_gradient::a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap`, `a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone`, `a_densely_flattened_dashed_pass_beside_a_wide_one_leaves_its_edge_alone`, `widest_ahead_is_the_widest_within_the_window`. Scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-06 | minor | Stroke gradients: an arrowhead whose tip sits on the end of a long straight leg is painted by the gradient to the corners of its base. The slices that reach across a head now run to the end of its reach instead of narrowing towards the next corner (#5964). Test: `stroke::tests_gradient::slices_cover_an_arrowhead_on_a_long_straight_leg`. Scores and estimates unchanged. | | 2026-11-06 | minor | Width profiles: the width between two width points follows a smooth curve instead of a straight line, so a stroke no longer kinks at each width point (#5714). Each side is a monotone cubic through its points: flat at a widest or narrowest point, never past either point of a span; stacked points still step. Straight runs are sampled between width points to the drawing tolerance. Scores and estimates unchanged. | @@ -57,6 +78,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-05 | minor | PDF import: a DeviceN gradient of a spot ink over process plates keeps the ink and the plates, and PDF export writes it as a DeviceN shading again (#3235); scores unchanged | | 2026-11-05 | minor | Optical kerning moves each pair part of the way toward the font's own stem spacing, with a minimum gap and tighter limits, and leaves ideographs, kana, fullwidth forms and dashes on the font's spacing, so text is no longer squeezed (#5336). Scores and estimates unchanged. | | 2026-11-05 | minor | Gradient panel: Delete Stop sits at the right end of the ramp, so it shows whenever the ramp does instead of under the stop fields, where a short panel hid it (#5403); scores and estimates unchanged | +| 2026-11-04 | minor | Record Select Text by Font's artboard and layer scope (#5529, a VectorCraft addition); scores and estimates unchanged. | | 2026-11-04 | minor | Type core: a Type-range Copy or Cut publishes styled art alongside plain text, so canvas Paste keeps font family, size and color after editing ends (#5636); scores and estimates unchanged. | | 2026-11-04 | minor | Selection Tools add-on: Select by Artboards selects the objects overlapping, fully inside or fully outside several chosen artboards, from a dialog that lists, searches and multi-selects the artboards and fits one in the window on double-click (#5525, #5548, #5549, a VectorCraft addition); scores and estimates unchanged. | | 2026-11-04 | minor | Raster/PSD defaults honor document resolution; CLI run/convert accept --ppi, matching MCP options.ppi precedence. Synthetic regressions added; scores and hours unchanged. | @@ -70,9 +92,11 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-03 | minor | Correct the Type core missing list: Hyphenation and Justification options are complete; the remaining Every-line Composer fidelity limits stay listed. Record the Kashidas paragraph lengths and per-character switch in the feature history (#5404). Scores and estimates unchanged. | | 2026-11-03 | minor | Ligature caret stops use GDEF contour-point positions in horizontal and vertical type at the font's default variation location; invalid point indices keep the evenly divided fallback. Scores and estimates unchanged. | | 2026-11-04 | minor | Record Find Font's replacement rows: family and style together, a search over them, and a scroll state of their own (#4972); scores and estimates unchanged. | +| 2026-11-03 | minor | Spiral decay applies once per full wind of four segments; newly drawn spirals with the same settings wind more broadly than earlier builds. Existing paths are unchanged. Scores and estimates unchanged. | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | | 2026-11-03 | minor | Type Tools add-on: Text Tweaker (a VectorCraft addition, #4924): evidence added; scores unchanged. | | 2026-11-03 | minor | Transform previews valid typed values while numeric fields remain focused. Effect dialogs use the object, group, or layer targeted in Appearance. | +| 2026-11-03 | minor | Help Bar has its own Window menu toggle and workspace visibility setting. Scores and estimates unchanged. | | 2026-11-02 | minor | Legacy `.ai` saves (version 7) write type with rotated characters as outlines that keep each character's rotation, with a warning unless `outlineText` is on (#5186); scores unchanged. | | 2026-11-03 | minor | Record shipped Find Font replacement sources: Document, Recent and System (#5217); scores and estimates unchanged. | | 2026-10-31 | minor | Object › Path › Smooth smooths a path along its length before refitting it, so Auto-Smooth takes a shaky line to one smooth curve instead of leaving its jags, and with anchor points direct-selected it smooths only that section; scores unchanged. | @@ -179,37 +203,38 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Area | Weight | Done | Main gaps | One agent (h) | Kind | |---|---:|---:|---|---:|---| -| Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); open-path anchor Delete keeps gaps; Select › Start Global Edit finds the paths, compound paths and symbol instances of the selected object's shape (Appearance, Size and artboard options) and gives an edit of one to all of them in proportion, as one undo step (#3521), and on a group the groups like it, an edit inside one reaching the same object in the others (#4939), and a Shift-click on a highlighted match leaves it out; the Align panel menu's Align to Glyph Bounds › Point Text and Area Text measure type by its glyphs' ink for Align and Distribute (#4231); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of rotated copies, transform nuances (no-op anchor Align history regression-tested, #1180 follow-up) | 4–6 | estimated; Global Edit regression-tested (`crates/engine/src/tests/globaledit.rs`; Quick Action, menu label and Escape in `panels::properties::tests::start_global_edit_quick_action_and_escape`) | -| Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged | +| Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); deletion splits an ordinary contour into selected sibling objects with bounds-fitted gradients kept in place (#3380, deletion only; `crates/engine/tests/it/delete_path_pieces.rs`); open-path anchor Delete keeps gaps; Select › Start Global Edit finds the paths, compound paths and symbol instances of the selected object's shape (Appearance, Size and artboard options) and gives an edit of one to all of them in proportion, as one undo step (#3521), and on a group the groups like it, an edit inside one reaching the same object in the others (#4939), and a Shift-click on a highlighted match leaves it out; the Align panel menu's Align to Glyph Bounds › Point Text and Area Text measure type by its glyphs' ink for Align and Distribute (#4231); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of rotated copies, transform nuances (no-op anchor Align history regression-tested, #1180 follow-up) | 4–6 | estimated; Global Edit regression-tested (`crates/engine/src/tests/globaledit.rs`; Quick Action, menu label and Escape in `panels::properties::tests::start_global_edit_quick_action_and_escape`) | +| Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123), score and hours unchanged | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522) | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors | 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (four generated libraries, User Defined, Other Library…, Save Brushes); remaining: Art brush Overlap, Pattern brush auto corners, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636) | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine done, #4936; dialog, Auto Spell Check and custom dictionary remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, more built-in symbol libraries, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check (engine done, #4936; dialog, Auto Spell Check and custom dictionary remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine done, #4936; dialog, Auto Spell Check and custom dictionary remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine done, #4936; dialog, Auto Spell Check and custom dictionary remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 52% | the Effect Gallery's filter thumbnails and in-dialog preview area ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)) | 1–2 | estimated (raised from 49%: the Effect Gallery shipped, #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `dialogs::effect_gallery::tests::stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; depth estimated | | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, more built-in symbol libraries, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration and Export Type (Graphic / Movie Clip) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path comes from VectorCraft's font metrics, and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged) | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); Control bar snapping quick access includes Snap to Glyph and Alignment Guides (#6164); new ruler-guide Alt orientation implemented; global/artboard ruler modes (`view.globalRulers`) and video rulers (View › Rulers › Show Video Rulers, #2849) done; status-bar X/Y uses the active ruler origin and General units; score unchanged | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | anti-aliasing ghost borders (#983); TIFF choices and settings are implemented | 2–4 | estimated | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity | 2–4 | estimated | | Automation (Actions, Variables, scripting, batch) | 3 | 60% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, image and graph variables, dataset import/export | 8–14 | estimated; MCP `add_text` exposes existing vertical type; score and hours unchanged | -| UI chrome (panels, Properties, workspaces, preferences) | 6 | 80% | Properties per context, 3 Preferences options stored but unused (#394, audited 2026-10-30: Open Documents As Tabs, Scratch Disks, legacy vector clipboard), SVG Interactivity, context menus beyond the canvas, accessibility | 13–20 | estimated; floating panels in OS windows of their own on the desktop (#4135), score and hours unchanged; macOS save Format menu implemented (#866), score and hours unchanged; Eyedropper cursor and precise setting checked (#1269), OS visual QA unverified; numeric-field focus and normal text drags regression-tested (#1326, in part); movements below the click threshold still select all; artboard navigation synchronizes the tool selection before destructive actions; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates (sample widths cached by language/unit/DPI; regression and contributor original-file evidence below; scores/hours unchanged); effect dialogs' colour fields show a swatch that opens the Color Picker (#5365), score and hours unchanged | +| UI chrome (panels, Properties, workspaces, preferences) | 6 | 80% | Properties per context, 3 Preferences options stored but unused (#394, audited 2026-10-30: Open Documents As Tabs, Scratch Disks, legacy vector clipboard), SVG Interactivity, context menus beyond the canvas, accessibility | 13–20 | estimated; floating panels in OS windows of their own on the desktop (#4135), score and hours unchanged; macOS save Format menu implemented (#866), score and hours unchanged; Eyedropper cursor and precise setting checked (#1269), OS visual QA unverified; numeric-field focus and normal text drags regression-tested (#1326, in part); movements below the click threshold still select all; artboard navigation synchronizes the tool selection before destructive actions; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates (sample widths cached by language/unit/DPI; regression and contributor original-file evidence below; scores/hours unchanged); effect dialogs' colour fields show a swatch that opens the Color Picker (#5365), score and hours unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), scores and hours unchanged | | Libraries, Links, Package | 2 | 82% | shared libraries, linked graphics, gradients and patterns as library items (no cloud by design) | 2–3 | estimated | | Generative AI (Text to Vector, Generative Recolor, Shape Fill, Concept to Vector, Prompt to Edit, Rewrite Text) | 2 | 0% | a pluggable provider and the panels; needs an owner decision on models | 30–60 | measured (absent) | | **Feature depth** | **105** | **~75%** | | **~250–420** | | @@ -361,7 +386,7 @@ what keeps it from beta, not from alpha. | Dimension | % | One agent (h) | Kind | Doc | |---|---:|---:|---|---| | Features (depth, weighted) | 75% | 220–360 | estimated | this file | -| UI/UX fidelity (look ~80%, interaction ~45%) | 55% | 70–110 | estimated | [ui-parity.md](ui-parity.md) | +| UI/UX fidelity (look ~80%, interaction ~45%) | 55% | 70–110 | estimated | [ui-parity.md](ui-parity.md); Tools sits inside left-side panels beside the canvas (layout choice; no rescoring) | | File formats | 75% | 15–30 | estimated | [file-format-parity.md](file-format-parity.md) | | Hardware (GPU, pen, touch, displays) | 35% | 25–45 | estimated | [hardware-parity.md](hardware-parity.md) | | Localization (the 12 key languages, equally weighted) | 74% | 50–85 | measured from catalogs | [localization-parity.md](localization-parity.md) | @@ -454,11 +479,11 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Strokes, brushes, width profiles | 5 | 90% | the Art brush's Overlap, auto-generated Pattern brush corners and new tile art in Pattern Brush Options (Brush Options for every brush type done), more built-in brush libraries (four generated in code are done), pen pressure on macOS (the windowing layer reports none there), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | -| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, 3D Classic and more complete visibility remain | 25–45 | -| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | +| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | +| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), tab leaders, spell check (its engine is done, #4936: the Check Spelling dialog, Auto Spell Check and the custom dictionary remain), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize) and Texturizer's Load Texture (since done, see the area table above, as is the Effect Gallery, #2883); Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur (Normal, Edge Only and Overlay Edge), Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 8–14 | -| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, 3D Classic and more complete visibility remain | 25–45 | -| Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko is done, its up/down settings remain; No Break is done), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | +| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | +| Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko is done, its up/down settings remain; No Break is done), tab leaders, spell check (its engine is done, #4936: the Check Spelling dialog, Auto Spell Check and the custom dictionary remain), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | more built-in symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | | Image Trace, graphs, image tools | 3 | 72% | graph Column designs (Graph Design, Marker designs and series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | @@ -515,6 +540,9 @@ since 2026-10-01. ## Evidence: what ships today, by area +- **Selection — disconnected path deletion (#3380, deletion only):** Clear/Delete and Cut turn surviving disconnected pieces of an ordinary contour into independent selected sibling objects. The first keeps its ID; all keep appearance, baked transforms and stacking order. `deletion_split_preserves_gradient_placement` compares rendered survivors with the original paint space and checks linear/radial fill/stroke vectors, already placed gradients and atomic Undo/Redo; connected remainders keep automatic fitting. Existing multi-subpath paths and explicit compound members retain their semantics. Original synthetic regressions in `crates/engine/tests/it/delete_path_pieces.rs` cover styled/transformed rectangles, cubic handles, compound preservation and atomic Undo/Redo; `crates/ui-egui/src/tests/removeanchors.rs` covers real-frame Delete/Backspace and a second Delete removing all survivors. Segment endpoint highlighting remains open. Scores and hours unchanged. +- **Panel collapse controls (#5925, in part):** The small » in a floating tab-group header moves that group into the side icon rail, reusing the existing rail or creating one. An opened rail panel has the same control to fold its flyout back into the rail, including after a tab switch. Both controls preserve the tab group and its menu; other floating groups and full-width docked headers stay as they were. Pointer regressions in `crates/ui-egui/src/panel_docking/tests.rs` cover grouped tabs, reopening, separate groups, a missing rail, the menu and flyout closure after switching tabs; `native_tests.rs` covers the native-window click and removal of the empty palette. Scores and estimates unchanged. + - 3D lighting presets: `crates/effects/src/lighting_presets.rs`, `crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs` and the pointer tests in `crates/ui-egui/src/dialogs/revolve/tests.rs` cover recognition, atomic targeting, native round trips, preview, Cancel, reset and Undo. - Window › 3D and Materials panel: `crates/ui-egui/src/panels/three_d.rs` applies a 3D effect by object type (Plane, Extrude, Revolve, Inflate), changes an applied one in place (`effect.setParams` `threeDType`) and edits it with the options dialog's Object, Rotation and Lighting controls, one undo step per change; `crates/engine/src/cmd/effectcmd/three_d_type_tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. No Materials tab. @@ -532,14 +560,20 @@ since 2026-10-01. - **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. - **PDF editable import:** Place from bytes or a path with `link: false`, and `clipboard.importPdf` followed by Paste, preserve live type on the selected page. Place still honors `textAs: "outlines"`. Synthetic two-page regressions: `crates/engine/src/cmd/fileio/tests_pdffidelity.rs`. No change to the PDF reader's existing fallback rules. +- **Tab stops in `.ai`/EPS type and right-aligned lines with tabs (#6147):** type from the text document keeps its paragraphs' tab stops (key 30: positions and left, center, right and decimal alignments; leaders aren't read), at most 1,024 a paragraph and 2^20 a story, past which the default stops apply with a warning. In right-aligned area type, the text before a line's last tab sits at its stops, measured from the left edge, and the text after it (none when the tab ends the line, with its hyphen when the line is hyphenated) against the right edge; centred and justified lines, point type and right-to-left paragraphs are aligned as a whole as before. Evidence: `ate::tests::a_paragraph_keeps_its_tab_stops`, `ate::tests::a_story_s_tab_stops_are_bounded`, `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`, `import_fuzz::text_frames_with_hostile_paragraph_attributes_never_panic`. Scores unchanged. +- **`.ai`/EPS type laid out closer to the file's page (#6137):** type from the text document keeps auto leading as auto, each line taking the paragraph's Auto Leading share (key 7) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)); the paragraph's hyphenation options (keys 10 to 15) are read; area type of one frame whose last lines the file sets, but which don't fit here, may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines and no more than the file has (`vectorcraft_text::fit_source_lines`), with a warning when it does: the frame can stay as it is, or show only some of them; and a line a soft return ends is justified as a wrapped line is, a paragraph's last line keeping its alignment. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_auto_leading`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores unchanged. - **Selected Grid Repeat handles (#2691):** `tools/src/repeat_grid.rs` tests place the handles outside the grid at zoom 0.5, 1 and 4, preview rows, columns and spacing, leave no undo step for a drag that ends where it began, and give bounding-box handles priority; a headless canvas regression drags the row handle two tiles down in a zoomed view (3 → 5 rows, the source unchanged, one Undo back) and the column-spacing slider. Scores and hours unchanged. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes, the distances of its later effects and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). Not yet: object effects after a Transform effect keep their distances. Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. +- **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. +- **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. +- **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (<https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. +- **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. @@ -549,6 +583,7 @@ since 2026-10-01. - **Pattern-only backquote drags:** With Selection move, bounding-box scale or rotate, or the Rotate, Reflect, Scale or Shear tool, backquote held at mouse release applies the drag matrix to selected pattern paint while object geometry stays in place. The key may be pressed after mouse press; if released before the mouse button, the drag commits as an ordinary transform. Objects without pattern paint stay still; stroke-only patterns and patterned objects in mixed selections take the same matrix. Regression tests: `backquote_move_uses_release_state_and_one_commit` and `backquote_box_scale_and_rotate_use_pattern_matrix_at_release` in `select/tests.rs`, `backquote_drag_previews_and_commits_pattern_only_for_each_transform_tool` in `xform/transform.rs`, `selection_backquote_drag_changes_pattern_fill_and_stroke_without_moving_mixed_art` and `selection_backquote_drag_changes_stroke_only_pattern` in `tests/pattern.rs`, `egui_backtick_drag_uses_key_state_at_release` in `canvas/tests.rs`, and `synthetic_pointer_backquote_keeps_solid_object_in_place` in `control/tests.rs`. Scores and hours unchanged. - **Existing-guide Escape cancellation (G1):** Escape clears a pending guide press or drag through Selection and Direct/Group Selection, cancelling a begun preview. Eight app-input regressions cover both axes, move/copy, temporary selection-tool borrowing from idle Pen, preselected-guide document/interaction-start selection and Undo/Redo/saved-state preservation, later on-canvas motion/release, Shift-press cancellation, and ordinary-release/Enter/idle controls. Historical focused checks on `8ab574b7` recorded five expected RED failures with three passing controls, then all eight GREEN tests and the Direct Selection Escape control passed. All nine also passed in current full CI. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. The original 1280×960 headless app capture from `b076025a` was reviewed for one vertical Selection guide after Escape; source review found its scene unchanged on `a0671777`. Scores and hours unchanged. - **Escape in more tool drags:** Escape during an Anchor Point drag, a Mesh tool drag, a Direct Selection drag of a mesh point, a corner widget or the brackets of type on a path, or a Shape Builder drag cancels it: the art goes back to how the press found it, no undo step is added, the release does nothing, and the tool stays chosen. Escape during a Measure tool drag brings back the measurement before it. Regression tests: `escape_cancels_*` in `draw2/anchor/tests.rs`, `meshedit.rs`, `direct/tests.rs`, `builder/tests.rs` and `xform/measure.rs`, and `escape_cancels_an_anchor_point_mesh_shape_builder_or_measure_drag` in `tests/toolescape.rs`. +- **Shift-click on corner widgets (#5188):** with Direct Selection, when every corner of a path is selected (the path selected whole), a Shift-click on a corner widget picks that corner alone, so the next drag rounds just it. The other corners' widgets then hide; a Shift-click on another corner's anchor adds that corner and shows its widget, and a Shift-click on a showing widget deselects its corner. Adding a corner by Shift-clicking its widget while it is hidden stays open (#1232). Corner widgets are chosen by clicking one and Shift-clicking the others ([Logos By Nick, rounded corners in Illustrator](https://logosbynick.com/?p=15787)). Test: `corners::tests::a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`. - **TIFF in screen and asset export:** both format menus expose the existing TIFF encoder and Format Settings. Headless UI regressions choose TIFF and check scaled dimensions, artboard selection, filenames, encoding options, saved settings and collected-asset output. Public reference: [Export for Screens](https://helpx.adobe.com/illustrator/desktop/save-and-export/export-files-to-different-formats/export-for-screens.html), updated 2026-09-28, lists TIFF for both artboards and assets. Adobe staff [confirmed support in 30.2.1](https://illustrator.uservoice.com/forums/333657-illustrator-desktop-feature-requests/suggestions/49081034-add-tiff-format-into-export-for-screens-assets-d) on 2026-02-19; the older 2025 Collect assets guide omits TIFF. - **Export for Screens of the selected artboards:** while the Artboard tool is in use, File › Export › Export for Screens… (`file.exportForScreens`) opens with the artboards the tool has selected checked on its Artboards tab, and Export Artboard writes only those. Shift-clicks on the canvas, or Shift- or Ctrl/Cmd-clicks on the Artboards panel's rows, select several. The other fields show the settings the document last exported with. With another tool in use, the dialog takes its artboard choice from those settings too. Public reference: [Export selected artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/export-artboards.html). @@ -572,6 +607,8 @@ since 2026-10-01. - **Character Style Options, General and Basic Character Formats:** the dialog edits a style's name and stored character formats together. Empty fields remain unset, checkboxes show an indeterminate unset state, Basic's Reset Panel clears its explicit settings, and Preview can be canceled without changing history. OK saves one undo step; New Character Style offers the same fields from captured text or the interface language's defaults without a selection. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Character Style Options, Advanced Character Formats:** Character Style Options and New Character Style set a style's Horizontal Scale and Vertical Scale (1% to 10,000%), Baseline Shift (-1296 pt to 1296 pt), Character Rotation (stored from -180° to 180°) and Language; text that uses the style takes them and keeps its local overrides. Empty fields remain unset, an explicit 100%, 0° or English: USA stays distinct from unset, and the section's Reset Panel clears only these five. Language sets the language the characters are shaped in, not hyphenation or spelling. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Character Style Options, Character Color and OpenType Features:** Character Style Options and New Character Style set a style's fill and stroke (None or a document swatch; a global swatch stays linked, with a Tint from 0% to 100%), stroke Weight, Overprint Fill and Overprint Stroke, and its Figure, Position, Standard and Discretionary Ligatures, Contextual, Stylistic and Titling Alternates, Swash, Ordinals, Fractions and Stylistic Sets 1 to 20; text that uses the style takes them and keeps its local overrides, and editing a global swatch later updates the style and its text. Empty controls remain unset, an explicit None, off, Default Figure or Default Position stays distinct from unset, each stylistic set is on, off or unset, a gradient or pattern paint stays until another paint is chosen, a changed OpenType control keeps the style's other feature tags, and each section's Reset Panel clears only its own attributes. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html). +- **Opening style options from the style panels:** a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), as the panel menu's item does; a paragraph style's row opens its Paragraph Style Options, the rename dialog (`ui.paramDialog` running `paraStyle.rename`) the panel menu opens. A single click still applies the style, the second click of the double-click does not apply it again, and the built-in Normal style opens no dialog. Test `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options`. +- **Character Style Options window size:** the dialog keeps a fixed width wide enough for the widest tab's controls and scrolls its sections inside a window shorter than them, so it stays on screen and its OK and Cancel buttons stay visible; the width is the same in every section and the height follows the section. Test `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. - **Text styles redefined to the defaults:** New and Redefine Character Style and Paragraph Style capture every attribute of the selected text, including those at their defaults (Auto kerning, Roman baseline alignment, the default Hyphenation options, the Every-line Composer, no tab stops), and `charStyle.setAttrs` and `paraStyle.setAttrs` keep an attribute set to its default value. After a redefinition, text whose value still equals the old definition's takes the new value, a default included; local overrides stay, and text that still has the old value of an attribute the new definition leaves out goes back to the Normal style's value. The style panels show "+" only where the text differs from its style, including type with Auto alignment. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Text styles dragged onto the panel's buttons:** a style dragged from the Character Styles or Paragraph Styles panel onto Create New Style is copied (`charStyle.duplicate`, `paraStyle.duplicate`, which name the copy `<name> copy`) and the copy is highlighted; dragged onto Delete it is deleted (`charStyle.delete`, `paraStyle.delete`); the Normal style is not deleted. Each drop is one undo step. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Auto Leading outside the layout:** a paragraph's Justification… › Auto Leading (<https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-word-and-letterspacing-in-justified-text.html>) also sets the Leading the Character panel shows for Auto type (blank where the selected paragraphs differ), the leading the Alt+↑/↓ nudge (`type.step`) steps from, each paragraph's own, and the lines Fill with Placeholder Text counts in area type. Evidence: `ParaStyle::effective_leading` (`crates/doc/src/text.rs` test `paragraph_effective_leading_follows_auto_leading`), `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading`. @@ -588,6 +625,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Width-point creation (G1):** double-click a stroked segment with the Width tool to open Width Point Edit using sampled side widths. OK creates the point and optionally adjusts adjoining widths in one undo step. Cancel or Delete discards a prospective point without changing the document or redo history. Command and pointer tests cover profiles, compound paths, adjoining adjustment, existing-point editing and Undo/Redo. Message-catalog checks and full CI passed; the original capture verifies opening and Cancel, and the dialog layout was reviewed (2026-10-13: 28 focused Width tests, all seven cargo xtask ci stages, and a headless opening/Cancel capture with original PNG review). [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html). - **Escape deselects width points:** with the Width tool, Escape deselects the selected width points, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for deselecting a width point. The path, its widths and the undo history do not change. With no width point selected, Escape does what it does with the other tools, such as leaving isolation mode. - **Smooth widths between width points (#5714):** a variable-width stroke changes width along a smooth curve from one width point to the next, where it used to run in a straight line and turn a corner at each point. The public description is that the path "gradually gets wider or narrower from one point to the next" in "a flowing, continuous curve", and that stacked points make a sharp break ([Peachpit, Expressive Strokes](https://www.peachpit.com/articles/article.aspx?p=2979069)); the exact curve is not documented, so each side follows a monotone cubic (Fritsch–Carlson): no kink at a point, flat where a point is wider or narrower than both neighbours, never past the points either side, points on one line kept on it. The outline samples straight runs between width points closely enough to follow the curve to the drawing tolerance (at most 64 samples per span and 4,096 per piece). Presets with a middle point (Lens, Pinch, Teardrop, Wave) look softer; two-point tapers are unchanged. Tests: `appearance::profile::tests::{widths_run_smoothly_through_width_points, widths_never_pass_the_points_either_side, a_step_ends_the_curve_on_each_side, widest_covers_every_width_along_the_profile}`, `stroke::width::tests::{a_straight_run_follows_the_curve_between_width_points, samples_between_width_points_are_capped}`. +- **Stroke gradients where a variable-width stroke meets itself (#5960):** with a gradient along or across a stroke that has a width profile, where the stroke crosses itself the later part paints over the earlier one only where the two overlap, and a narrow part running close beside a wide one leaves its edge alone. Each slice of the gradient reaches as far as the stroke is wide where it sits (for a dashed stroke, as wide as it gets within the reach of a cap or dot, found in one pass along the path, so a densely flattened path costs no more per point), where it used to reach as far as the stroke is wide anywhere and paint a square of its colour over the wider part. Strokes without a profile are unchanged. Tests: `stroke::tests_gradient::a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap`, `a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone`, `a_densely_flattened_dashed_pass_beside_a_wide_one_leaves_its_edge_alone` and `widest_ahead_is_the_widest_within_the_window`, with the existing coverage tests (joins, caps, arrowheads, profiles, dashes, aligned strokes). - **Width strokes zoomed out (#995):** a stroke with a width profile keeps its shape while the view zooms out. The canvas keeps a hairline visible (at least half a device pixel); for a profiled stroke it is the profile's widest point that gets that minimum, not the weight, so a 1 pt stroke with a 6x width point is no longer drawn twice as thick at 25% zoom than at 100%. Plain strokes are unchanged. Test: `render::tests::widthzoom`. - **Rulers (#1212):** drag the intersection to preview and set a document zero point, or double-click it to reset to the active artboard's top-left. Transform, Properties and Control X/Y fields measure from it and convert entered positions back to absolute command coordinates. The origin survives every native format version and compression; geometry, guides and patterns stay in place. Public reference: [Use rulers](https://helpx.adobe.com/ca/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html). - **Global and artboard rulers:** View › Rulers › Change to Global / Artboard Rulers (Cmd+Alt+R, `view.globalRulers`). A new document has artboard rulers: the rulers and the Transform, Properties, Control and Info X/Y count from the active artboard's top-left corner, or the zero point dragged out for that artboard, which moves with it; with global rulers one document zero point serves every artboard. Guide readouts and Shift ruler-tick snapping follow the same zero point. The mode is saved with the document; files from before it open with global rulers, as they measured. Regression tests: `engine/tests/it/ruler_origin.rs`, `ui-egui/src/tests/ruler_origin.rs`, `tools/src/rulerguide.rs`. Public reference: [About rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html). @@ -615,20 +653,24 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Artboard Escape routing:** Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. - **Escape in the distort tools:** Escape during a Puppet Warp pin drag or turn, a Perspective Grid tool handle or plane widget drag, a Perspective Selection move, resize or plane widget drag, or a Liquify stroke cancels it: the art goes back to how the press found it, no undo step is added, and the tool stays chosen. Puppet Warp also selects again the pins selected before the press, and Escape during a Liquify Alt-drag puts the brush size back. Regression tests: `escape_cancels_*` in `crates/tools/src/distort/puppet.rs`, `perspective.rs` and `liquify.rs`, and `escape_cancels_a_puppet_warp_perspective_or_liquify_drag` in `tests/distortkeys.rs`. - **Shift with the Artboard tool:** a Shift-click adds an artboard to the selection or takes it out when the button comes up. A Shift-drag inside an artboard draws a new artboard there, and a Shift-drag on the pasteboard is a marquee that toggles the artboards it touches, as the [default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (a Shift-drag draws an artboard within an existing one) and [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy) describe. Shift no longer keeps an artboard move at 45° steps or a drawn artboard square. Escape during a marquee or while a new artboard is being drawn cancels it and keeps the Artboard tool. Pointer and key tests in `crates/tools/src/xform/artboard.rs` cover the four gestures and Escape. +- **Artboard edge buttons:** with the Artboard tool and one artboard selected, a button with a plus shows just outside the middle of each edge where an artboard of the same size fits on that side; clicking it adds an empty artboard of that size there (`artboard.new {beside, side}`), and Alt-clicking duplicates the artboard with its art there (`artboard.duplicate {index, side}`), each in one undo step, with the new artboard active. An Alt-click on a locked artboard's button does nothing, as the tool doesn't copy a locked artboard. The 20 pt gap and the room rule (within the canvas, overlapping no other artboard) are VectorCraft's own. Pointer tests in `crates/tools/src/xform/artboard/tests.rs` and an engine test in `tests/panelcmds.rs` cover it. Public references: [Add artboards instantly](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/add-new-artboards.html) and [Duplicate artboards instantly](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/duplicate-artboards.html). - **Artboard deletion keys:** Delete/Backspace delete selected boards in one Undo step, retaining the original first board in the current order when all are selected, as in the public [Delete artboards guide](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html). The last board and active drags stay protected; neither key clears selected artwork. Headless shortcut regressions in `tests/distortkeys.rs` cover reordered boards, artwork, saved state and Undo/Redo. - **Locked artboards:** a click in the Artboards panel's lock column locks or unlocks an artboard in one undo step (`artboard.setProps {index, locked}`), and the native format saves the lock. The Artboard tool doesn't move, copy, resize or delete a locked artboard, Rearrange and Fit to Artwork Bounds leave it as it is, and its name isn't edited on the canvas. The art on it (each object whose bounds overlap it, a group as one object) acts as locked art until it is unlocked: clicks, marquees, `select.set`, `select.all` and the tools pass over it, and commands that act on the selection leave it; art drawn or pasted onto it stays selected and editable until the selection leaves it out. Tests in `crates/doc/src/lib.rs`, `tests/panelcmds.rs`, `xform/artboard.rs`, `panels/artboards.rs` and `canvas.rs` cover them. Public references: [Lock artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/lock-artboards.html) and [Managing Artboards in Illustrator](https://creativepro.com/managing-artboards-in-illustrator/). - **Linux RPM packaging:** 64-bit runtime capabilities replace Fedora-specific required package names (#1275); glibc 2.35, libm and libgcc_s are declared alongside dynamically loaded xkbcommon and Vulkan/EGL. Generated x86_64/aarch64 metadata and isolated synthetic-provider resolution checked; real-distro install/start-up QA remains pending. -- **Linux file associations:** the desktop entry lists the MIME type of every format File › Open reads but PSD, PSB, `.ps` PostScript, Targa, PCX, Pixar and JPEG 2000, `.ai`, `.ait` and EPS included, and the MIME package declares the types shared-mime-info lacks (VectorCraft documents and templates, `.ait`, Affinity), so file managers list VectorCraft under Open With for them, as Finder does on macOS. `the_linux_desktop_entry_opens_every_readable_format` in `apps/vectorcraft/src/tests.rs` checks both files against `OPEN_EXTS`. On Debian 13, Ubuntu 24.04 and Fedora 42, `desktop-file-validate` accepts the desktop entry, `update-mime-database` accepts the MIME package, and `gio` registers the app for every one of these types. +- **Linux file associations:** the desktop entry lists the MIME type of every format File › Open reads but PSD, PSB, `.ps` PostScript, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF and JPEG XL, `.ai`, `.ait` and EPS included, and the MIME package declares the types shared-mime-info lacks (VectorCraft documents and templates, `.ait`, Affinity), so file managers list VectorCraft under Open With for them, as Finder does on macOS. `the_linux_desktop_entry_opens_every_readable_format` in `apps/vectorcraft/src/tests.rs` checks both files against `OPEN_EXTS`. On Debian 13, Ubuntu 24.04 and Fedora 42, `desktop-file-validate` accepts the desktop entry, `update-mime-database` accepts the MIME package, and `gio` registers the app for every one of these types. - **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: when no object is bound to a variable that the data set has a value for, for example after Import Data Sets… defines new variables without binding them, choosing the data set in the Variables panel or stepping to it with the panel's arrows shows a status message, and `dataset.select`, `dataset.next` and `dataset.prev` add the message to the reply as `warning`. - 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. - Batch…: Override Save and Override Export name each file with the extension of the format the step writes, however the step spells the format (`.svg` and `SVG` give `.svg`, `png8` gives `.png`), in `action.batch`. + - Batch…: Override Export writes the files of an Export for Screens or Export Assets step into a folder for each source file or data set inside the destination folder, under the names the export gives them (`action.batch`). The folder and file names drop trailing periods and spaces (`a..svg` gives `a`). When two source files would get the same name that way (compared without case), or a later file's folder or file would take the name of an output an earlier file wrote, of a source file in the destination folder or, for data sets, of a file already there, Batch adds `-2`, `-3`… to the later one's name. - Actions panel menu: Save Actions… writes the current action set with all its actions to a file, and Load Actions… adds the set from such a file to the panel ([Manage a set of actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/manage-a-set-of-actions.html); `ui.actionSet.save`, `ui.actionSet.load`). The file is VectorCraft's own action set file (`.vcactions`); a loaded set named like one in the panel gets the next free number. + - Actions panel menu: Load Actions… also reads action sets saved as `.aia` files ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `actionSet.read`, `ui.actionSet.load`). VectorCraft's own rules: the set comes in with all its actions. A step with a label and no recorded values comes in as the one menu command with the step's label, when that command is directly in a top menu, is what its menu bar item runs with no values and opens no dialog. A step that is off in the file stays excluded from playback. The other steps, recorded menu items included, are left out, and the status bar says how many. - Actions panel: the panel menu's Action Options… renames the selected action and keeps its steps ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): the menu item opens a dialog that renames the action or gives it a function key and color; `ui.action.options`). It also sets the action's function key and color. - Actions panel: Create New Action (bottom bar) and the panel menu's New Action… ask for the new action's name, set, function key and color, and Record adds the action and starts recording into it ([Create new actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/record-actions.html): the New Action dialog takes the action's name and the set it goes in; `ui.action.new`, `ui.action.play`). The function key (F1 to F15, with Ctrl or Command and Shift) plays the action. An action's function key plays the action even when a command has the same shortcut, and the command keeps it. VectorCraft's own rules, which the page does not cover: when several actions have the same key the first in panel order plays; a copied action has no key; New Action does not open while an action is recording. The color is kept with the action for Button Mode, which VectorCraft does not have yet. - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. @@ -640,18 +682,20 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel: the panel menu's Duplicate copies the selected action, else the current action set with all its actions ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.duplicate`). VectorCraft's own rules, which the overview does not cover: the copy goes right after the original, is named after it with " copy" added and is selected. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. - Batch… offers File + Number, File + Data Set Name, and Data Set Name for a Data Sets source. The dialog defaults to File + Number; `action.batch` without `datasetFileNaming` keeps File + Data Set Name. An output already in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix. [Batch options](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/batch-options.html). + - Batch… over data sets offers None, Save and Close and Folder as the destination, as for a folder source. Save and Close saves the open document over its own file after each data set, so the file keeps only the last data set, unless a recorded Save step that Override Save points into the destination folder first makes each data set's file the document's own. A recorded Save, Save a Copy, Save as Template or export step that writes a file and that the batch does not point into the destination folder (destination None, Override Save or Override Export off, or a copy, template, PDF, EPS, DXF, Save for Web or Save for Office Documents step) also writes the same file for every data set. In either case the dialog shows a warning and `action.batch` returns a `warning`. Unless a recorded Save or Save As step writes a file and makes it the document's own first, `action.batch` refuses Save and Close over the data sets of a document that was never saved, was converted from an older version or was restored by Data Recovery. - Export for Screens: the dialog's Example line shows the path of the file the export writes first, sub-folder included, named as the export names it, for the first chosen artboard, asset, layer or object (`document.exportForScreens`). - Export presets: the Asset Export panel menu lists the export presets of Export for Screens, with Import Preset… and Export Preset…, which read and write `.vcexportpresets` files (`document.exportPresets.import`, `document.exportPresets.export`). An imported preset whose name is taken gets a number. In the panel, a preset applies to the shared export settings (`assets.settings.set {exportPreset}`), and Save Preset… saves them. With Create Sub-folders off, the panel's Export writes every file into the folder itself, mobile and density rows included. - Export for Screens is laid out as the reference app's dialog: on the left the Artboards and Assets tabs (thumbnails with their checkboxes, number and name, in a grid or a list), Clear Selection and Prefix; on the right Select (All, Range, Include Bleed, Include Artboard Fill, Full Document), Export to, Open Location after Export, Create Sub-folders by Scale or by Format (on by default), Export PDFs As (Single File writes the chosen artboards as the pages of one PDF named after the document, `pdfs: "single"`), and the format rows with the iOS and Android buttons, the format presets (☰) and Format Settings; under them, how many items are selected and how many files the export writes. File name patterns, the Layers and Selection sources and per-item folders stay available through `document.exportForScreens` (scripts and MCP). Tests: `dialogs::tests_screens` (layout, iOS/Android, Clear Selection, sub-folders, Single File) and `fileio::tests_screens::single_file_pdfs_hold_every_chosen_artboard`. - Save for Web: Preview in Browser writes each preview's page and images to a folder of its own inside `VectorCraft Save for Web` in the system's temporary folder. A later preview, from the same app or another one running, does not overwrite an earlier one. Each preview first removes the preview folders there that are an hour old or older and, on Linux, macOS and FreeBSD, belong to the current user; it leaves other files and folders alone. The folders a preview makes there are open to their owner only on those systems. When `VectorCraft Save for Web` is a link or is not a folder, or on those systems belongs to another user or can be written by other users, the preview goes to a new folder of its own in the temporary folder, which a later preview removes once it is an hour old (`file.saveForWeb.browser`). - Export for Screens, Save for Web (slice images, and files named after the document's title) and the Libraries panel name files with the characters Windows does not allow written as `-` and with a `-` after a Windows device name such as `CON`, on every platform (`document.exportForScreens`, `document.exportForWeb`, `library.create`). + - 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. - Export for Screens numbers distinct row files whose names collide, compared without case, as `-2`, `-3`… instead of leaving them out; identical rows still write one file. It puts row folders that differ only in case into one folder (`document.exportForScreens`). - - Variables (data merge): text, visibility and image variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility/Image Dynamic, Capture/Update Data Set, CSV import/export (`dataset.import` / `dataset.export`), and a row in italics when an edit leaves the art not matching it; an image dataset value is a file path and apply keeps the object's bounds; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI. - Variables: Import Data Sets… (`dataset.import`) reads tab-separated files as well as comma-separated ones. Every header cell is a variable, and the rows replace the document's data sets as Data Set 1, Data Set 2 and so on. An import error about a row's values gives the file line the row starts on. - Variables: Import Data Sets… (`dataset.import`) defines the variables a file's header names that the document lacks, unbound. `#Shown` defines a visibility variable, `@Photo` defines an image variable, and a header without a prefix defines a text variable. A header that is a variable's exact name keeps naming it, and any other `%` header (graph data files) is refused. - Variables: Load Variable Library… in the Variables panel menu (`variable.loadLibrary`) replaces the document's variables and data sets with a variable library's, read from its XML file, in one undo step: every data set is deleted, and every variable is deleted with its bindings and values, except a text variable the library also declares, which stays bound so the library's data sets change its objects (#4136); then the library's other text variables are defined, unbound, and its data sets take the text of each value's `p` elements, one paragraph each. Variables of other kinds are left out with a warning that names their kinds. - Variables: Save Variable Library… in the Variables panel menu (`variable.saveLibrary`) writes the document's text variables and data sets as a variable library's XML file, which Load Variable Library reads back with the same text variables, data set names and text values. Variables of other kinds are left out with a warning that names their kinds. - JSON-lines control channel with real egui pointer and keyboard injection. + - Variables: a type object holds one text variable. `variable.bind` and Make Text Dynamic refuse a second one and name the text variable the object has; a visibility variable can still share the object, and a document saved with two text variables on one object opens with both. - Synthetic input validates complete gestures before queue mutation: at most 256 drag steps, 16 clicks and 1,024 pending events; up to 64 control requests per frame, with later requests retained for the next frame (#1709). - The control server writes each reply through a 64 KiB buffer. On Windows 11 it sends the 17 MB `document.inspect` reply of a document with 50,000 objects on one layer to a client over loopback in 0.09 s instead of 34 s. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. @@ -659,7 +703,10 @@ 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`). 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 two entries list. 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: 4,280 of 4,286 runs pass (4 of the others expect the completion values of later editions, and 2 expect a configurable function `length`). 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. A method visits only the indices that exist when its range is longer than 4,096 indices and more than 8 times the properties on the object and its prototypes, so `new Array(4294967295).join("")` ends at once; a shorter range is visited index by index, holes included, and each index takes fuel. `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: 6,798 of 6,866 runs pass (the 34 failing tests expect rules of later editions). It also fetches the `json2.js` polyfill at a pinned commit and checks that its `stringify` and `parse` agree with the built-in `JSON`. Tests: `crates/js/src/builtins_tests.rs`, `crates/js/tests/test262.rs`, `crates/js/tests/json2.rs`. + - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,379 of 1,411 pass for `String`, 298 of 298 for `Number`, 954 of 988 for `RegExp` and 8 of 8 for `Date`. The others expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.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. @@ -687,7 +734,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - Window › Toolbars › New Toolbar… and New Toolbar… in the All Tools drawer's menu ask for a name and make a blank toolbar that floats on its own, moved by its title bar and listed under Window › Toolbars, whose item shows or hides it. The title bar's × hides it, and its double arrow shows its tools in one column or two. Its ••• button opens the All Tools drawer beside it, and tools dragged from the drawer fill it. The toolbars are kept with the workspace they were made in: switching workspaces shows that workspace's toolbars, Reset Workspace keeps them, and New Workspace starts the new workspace with a copy of them (<https://helpx.adobe.com/illustrator/using/tools.html>; `window.toolbar.new`, `window.toolbar.custom`, the `toolbar` param of `window.editToolbar` and `toolbar.addTool`/`removeTool`/`reset`; `tools_added_to_a_new_toolbar_survive_a_workspace_reload`). - Window › Toolbars › Manage Toolbars… and Manage Toolbars… in the All Tools drawer's menu open a dialog that lists the toolbars made with New Toolbar: a new name typed for the selected toolbar renames it, + copies it with its tools (with none selected, + makes a blank toolbar), and the trash can deletes it. Each change shows under Window › Toolbars and stays in the workspace (<https://helpx.adobe.com/illustrator/using/tools.html>; `window.toolbar.manage`, `window.toolbar.rename`, `window.toolbar.duplicate`, `window.toolbar.delete`; `manage_toolbars_renames_duplicates_and_deletes`). + - Window › Toolbars › Keep Tools Beside the Canvas, a VectorCraft layout option that is off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge; the canvas's left edge then also docks a floating Tools panel, and workspaces keep the setting. Scores are unchanged (`window.toolsBesideCanvas`; `docked_tools_keep_the_window_edge_unless_kept_beside_the_canvas`, `tools_redock_at_the_targets_of_the_chosen_placement`). - Hindi (2026-11-06): 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. [Terminology and limitations](localization-hi.md). + - Window › Help Bar shows or hides the tool-help strip at the bottom of the window apart from the status bar (`window.helpBar`). Each setting is kept with the UI preferences and in saved workspaces, and Presentation Mode hides both bars. Layouts saved before the setting existed show the Help Bar where they show the status bar (`help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar`, `a_help_bar_setting_the_file_lacks_is_saved`). - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Polish, Brazilian Portuguese, Russian, Ukrainian, Georgian and Catalan ship complete, and Czech covers every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). @@ -700,10 +749,14 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The History panel menu's Clear History (`edit.clearHistory`) removes every step to undo and to redo and leaves the document as it is; it is not an undo step itself, and it is disabled when there is no step to clear. - The New Document from State button at the bottom of the History panel (`edit.newDocumentFromState`) makes a new document, which becomes active, from the state the panel has selected ([public source](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/undo-edits-and-manage-design-history.html)). VectorCraft's own rules: the new document takes the next Untitled-N name, is marked unsaved, has nothing selected and has no steps to undo or redo, and the source document and its steps stay as they are. - The Transform panel menu's Transform Object Only, Transform Pattern Only and Transform Both (`transformPanelPatterns`) choose whether the fields, flips and menu flips of the Transform panel and the Properties panel's Transform section move the object and leave its pattern tiles, move the tiles alone, or both (until a choice they follow General › Transform Pattern Tiles, as before); Use Registration Point for Symbol (`useRegistrationPointForSymbol`) measures and transforms one selected symbol instance from its symbol's registration point, where the instance's flips, rotations and shears put it ([Transform panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/transform-panel-overview.html); `panels::transform::tests::the_menu_chooses_whether_the_object_its_pattern_or_both_transform`, `use_registration_point_for_symbol_measures_an_instance_from_its_registration_point`, `use_registration_point_for_symbol_follows_a_transformed_instance`, `tests_prefs::transform_panel_patterns_defaults_to_following_the_general_preference`). + - The Transform panel's Align to Pixel Grid checkbox (`object.setPixelAligned`) gives paths and compound paths a pixel-aligned property, saved in the native file: their horizontal and vertical straight segments sit on whole points (half points under an odd centered stroke) with whole stroke weights, and every move, scale, rotation, reflection, Transform Each, Align move, Transform panel change, move of direct-selected anchors or of one anchor, move of a compound path's path on its own, artboard move, resize or copy that carries the art, Duplicate or paste that moves a copy, stroke edit and appearance edit that adds, removes, hides, repaints or reweighs a stroke snaps them again, while diagonal segments and curves keep their shape; a compound path made from an aligned path or compound path is aligned and snaps its paths with its own stroke; the box shows a dash for a mixed selection and is unavailable for type, images and groups ([Pixel-aligned property](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/pixel-preview-mode-for-web-graphics.html); `tests::pixelalign::turning_it_on_snaps_the_path_in_one_undo_step`, `it_stays_aligned_through_transforms`, `it_stays_aligned_when_its_artboard_moves`, `diagonal_segments_and_curves_keep_their_shape`, `type_and_images_cannot_be_aligned`, `the_property_is_saved_and_read_back`, `stroke_edits_keep_it_aligned`, `a_compound_path_of_aligned_paths_is_aligned`, `a_compound_path_of_an_aligned_compound_path_is_aligned`, `a_stroke_without_paint_snaps_to_whole_points`, `moving_direct_selected_anchors_keeps_it_aligned`, `moving_one_anchor_keeps_it_aligned`, `moving_a_path_of_an_aligned_compound_path_keeps_it_aligned`, `artboard_options_and_artboard_copies_keep_it_aligned`, `appearance_edits_keep_it_aligned`, `placed_gradients_follow_the_snapped_path`, `duplicates_and_pastes_keep_it_aligned`, `paths_added_to_or_moved_in_an_aligned_compound_path_keep_it_aligned`, `the_art_of_a_linked_opacity_mask_keeps_it_aligned`, `a_linked_opacity_mask_follows_a_snap`, `aligning_direct_selected_anchors_keeps_it_aligned`, `global_edit_keeps_its_members_aligned`, `a_path_with_live_corners_snaps_its_inner_edges`, `geometry_edits_keep_it_aligned`, `other_path_edits_keep_it_aligned`, `panels::transform::tests::align_to_pixel_grid_shows_and_sets_the_property`). - Object › Transform › Reset Bounding Box squares the box of every object inside the selected ones as well: the children of groups, compound paths, clip groups, blends, envelopes, repeats and intertwines, and the art of linked opacity masks. Ungroup and the release commands then bring back boxes square to the page (VectorCraft's own behavior; `bboxrotate::reset_bounding_box_squares_the_objects_inside_a_group`, `reset_bounding_box_squares_the_turned_members_of_a_square_group`, `reset_bounding_box_then_release_clipping_mask` and the other `reset_bounding_box_then_*` tests, `reset_bounding_box_leaves_the_art_of_an_unlinked_opacity_mask`). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`). - The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`). + - Export commands run through MCP, the control channel or the CLI refuse a key that no format takes, and a command that writes one format (`document.exportPdf`, `document.exportEps`, `document.exportDxf`) or takes one format's options (`document.pdfSettings`, `pdf.preset.save`) refuses a key that format doesn't take. An unknown key inside `marks`, `bleed`, `compression`, `output`, `advanced`, `security` or `flattener` is refused by its path. + - Save commands run through MCP, the control channel or the CLI (`document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate`, `app.save`) refuse a key that no save format takes, beside the path or in `options`. Export for Screens refuses a key it doesn't take, and a key that no format takes in a format row or in a format's settings, by its path (`formats[1].marks.useDocumentBleed`); Export Assets (`assets.export`, `assets.settings.set`) checks the rows and settings it is given the same way. + - Commands that read their `ids` (or `slices`) list themselves refuse a list with an entry that is not a non-negative integer, and a value that is not a list, and change nothing; they no longer drop the entry and act on the rest (Copy Appearance From, the Edit Colors commands and Overprint Black, Find and Replace, `layer.highlight`, `layer.move`, `library.add`, the Links panel commands, `object.transform`, Make Pattern, the Repeat commands, the slice commands, Join, Average and Join, `plugin.run`, `symbol.edit`, Make Opacity Mask; `tests::ids_without_selection::an_ids_entry_that_is_not_an_id_is_refused_and_changes_nothing`). The error for an entry of an object id list names the key, the entry and its value ("`ids` entry 1 is true, not a non-negative integer object id"), with a row in every catalog that has the other object id errors. - The errors of Enter Isolation Mode (`object.isolate`) for a type object, for hidden or locked art and for a call with no `id` while nothing is selected ("missing id", which other commands that take an `id` also give), and the error of the Layers panel's target circle (`layer.target`) for an object outside the isolated group, show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch. - An EPS or PostScript file that stops on a PostScript error gives the error's name as PostScript spells it (`typecheck`, `rangecheck` and the other names the reader raises, except `undefined`, whose message names the unknown name) followed by what it means, in the warning after opening and in the error when the file can't be opened. The status bar and the open-error dialog show the meaning in the interface language: each meaning has a row in every catalog (`postscript_error_meanings_are_translated`). - The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`. @@ -712,14 +765,14 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Symbol libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)): Window › Symbol Libraries and the Symbols panel's Symbol Libraries Menu open each library in a library panel of its own, as swatch and graphic style libraries do. The built-in library, Basic Shapes, is original art generated in code (`crates/engine/src/cmd/symbollib.rs`). A click on a symbol adds it to the document's Symbols as one undo step, with its Symbol Options (size, registration, export type, 9-slice guides) and every resource it uses (the symbols it nests, patterns, swatches, brushes, graphic styles, images), and makes it the current symbol; a symbol the document already has is not added again and another symbol of the same name keeps its name, the library's comes in numbered. Thumbnails, in the library panels and the Symbols panel, draw a symbol with the symbols it nests and the patterns and images it uses. Dragging a library symbol onto the canvas also places an instance there, in the same step ([placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)). Save Symbols (the library menu) and Save Symbol Library… (the Symbols panel menu) write the document's symbols as a `.vectorcraft` document holding only them, into the user library folder (listed under User Defined) or a file; Other Library… reads the symbols of any document VectorCraft opens; a file it can't read is an error and opens no panel. Tests: `crates/engine/src/tests/symbollib.rs`, `crates/ui-egui/src/panels/tests_symbol_libraries.rs`, `panels::symbols::tests::thumbnails_show_the_symbols_a_symbol_nests`, `canvas::tests::a_library_symbol_dropped_on_the_canvas_is_added_and_placed_there_in_one_step`. - **9-slice scaling** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): a scaled instance of a symbol with Enable Guides for 9-Slice Scaling on stays an instance and keeps the parts of its art outside the guides at their size. New guides sit a quarter of the symbol's width and height in from each edge ([reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf), Use 9-slice scaling section); saved guides keep their values. The rest is VectorCraft's own rule: the corners keep both sizes, the top and bottom edges stretch in width, the left and right edges in height and the center in both; an instance smaller than its two outer cells on an axis shrinks them in proportion and leaves the center empty; the cells are sized along the symbol's axes before the instance's rotation, shear or reflection. A path with solid paint that crosses a guide is cut there and stays one path, its stroke at its weight with Scale Strokes & Effects on or off; type, images, live objects and gradient or pattern paint that cross a guide are drawn in each cell they reach, clipped to it, except those that draw symbol art (a nested instance, type with an inline graphic, a placed document, a brushed stroke, or a pattern whose tile draws one of these), which are placed by the cell their center is in; an object in one cell is placed by that cell and stays as it is. The canvas, bounds, the Eyedropper, Break Link to Symbol and the PDF, EPS, DXF, EMF/WMF and SVG exports place the art the same way; SVG writes such an instance as its placed art instead of a `<use>`, and DXF and WMF, which do not clip, cut every path that crosses a guide and place other such objects by the cell their center is in. Tests: `symbol_nine_slice::tests` in `crates/doc`, `crates/engine/src/tests/symbol_nine_slice.rs`, `a_scaled_nine_slice_instance_keeps_its_corners` (PDF), `a_scaled_nine_slice_instance_is_written_as_the_canvas_draws_it` (SVG), `a_scaled_nine_slice_instance_is_sampled_as_it_is_drawn` (Eyedropper). - **Symbol editing mode** ([Edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): a double-click on a symbol instance, the Control bar's Edit Symbol button, a double-click on a symbol in the Symbols panel and the panel menu's Edit Symbol (`symbol.edit`) open the symbol's art on a temporary isolated layer, where the instance shows it (from the panel: centered on the active artboard, or in the view in a document without artboards), with the rest of the document dimmed. Each edit there changes every instance at once; the instances keep their own transforms and stay linked (Break Link to Symbol still expands one), and a dynamic symbol's instances keep their appearance changes to the objects the art still has. Escape, the isolation bar's back button, the Control bar's Exit Symbol Editing Mode and a double-click outside the art leave the mode (`symbol.exitEditing`); leaving without an edit adds no undo step. Saving leaves the editing layer out. Tests: `crates/engine/src/tests/symboledit.rs`, `select::tests::a_double_click_on_a_symbol_instance_edits_its_symbol`, `panels::symbols::tests::edit_symbol_from_the_panel_centers_the_art_on_the_active_artboard_or_the_view`. + - **Dynamic symbol appearance follows what an object draws** (VectorCraft's own rule): `symbol.instance.setAppearance` refuses a fill or stroke for an object of the art that does not draw it (a guide, blend, envelope, Repeat, Intertwine group, gradient mesh or placed document; a nested symbol instance's stroke, and its fill unless a solid color or none, which stains its art; an image's fill or stroke without a Convert to Shape or Outline Object effect) with an error that names the key and the object, and the call changes nothing. Opacity, blend and `null`, which takes a stored change back, are taken for every object. Tests: `crates/engine/src/tests/dynamic_symbols.rs`. - Symbol editing mode keeps every instance in step: instances of a dynamic symbol inside other symbols' art, pattern tiles, graph designs, stored brushes and opacity masks keep their appearance changes on the objects an edit reorders and drop the changes to objects it deletes. The view does not draw the instance the edit was entered from. Area type in the symbol's art takes the text wrap that moving a wrap object gives it. Editing another symbol at a place that fails keeps the current edit open, and only the editing layer or art on it can be isolated. Tests: `crates/engine/src/tests/symboledit.rs`, `isolation::symbol_editing_does_not_draw_the_instance_it_was_entered_from`, `panels::symbols::tests::double_clicking_a_symbol_in_the_panel_edits_it_in_either_view`, `the_panel_menus_edit_symbol_edits_the_instances_symbol_or_the_panels`, `the_control_bars_edit_symbol_edits_the_selected_instances_symbol`, `chrome::tests::the_control_bar_exits_symbol_editing_mode`. + - **Dynamic symbol instances on the canvas** ([Symbols, archived copy](https://web.archive.org/web/20240206075757/https://helpx.adobe.com/illustrator/using/symbols.html)): each instance of a dynamic symbol can have an appearance of its own while it shares the symbol's shape. The rest of this entry is VectorCraft's own rules. A Direct Selection click on an instance of a dynamic symbol selects the path, compound path or type object of the symbol's art under the pointer and outlines it (`symbol.instance.selectPart`); the Swatches and Color panels, the Control bar, the Eyedropper and the other fill and stroke controls then change that object's solid color fill or stroke for this instance only, each as one undo step, and the symbol's art and its other instances stay as they were. Alt or Shift picks the instance whole. A gradient or pattern for the object, an Eyedropper source that is not a path, compound path or type, a click on any other object of the art (a group, image, blend or nested instance) and the commands that change the selected objects (opacity, moving, Delete, Group, Hide, Lock, Arrange, Expand, the gradient commands, the Appearance panel's rows and others) are refused with a status message. Copy, Export Selection, Fit to Selection, Liquify, Isolate, Add to Library and Save Selection use the whole instance (`docs/mcp.md`), and another selection tool selects the instance whole again. Tests: `crates/engine/src/tests/dynamic_symbols.rs`, `direct::tests::a_click_on_a_dynamic_instance_picks_the_object_under_the_pointer`. - **9-slice guides on the canvas** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): while a symbol with Enable Guides for 9-Slice Scaling on is edited in symbol editing mode, the canvas draws its two horizontal and two vertical guides as dotted lines across its art, with any tool and with ruler guides hidden. With the Selection tool the pointer near a guide shows the Move cursor, and dragging the guide moves it (`symbol.options`'s `nineSliceGuides`) as one undo step; Escape during the drag puts it back. VectorCraft's own rules: a guide within 4 screen pixels takes the press before the art and the bounding box; it stays within the art's box (guides outside the art are not supported yet) and stops short of the opposite guide; the drag does not snap. Tests: `nineslice::tests`, `symbol_nine_slice::tests::the_edited_symbols_guides_cross_its_art_where_the_editing_layer_shows_it`, `a_moved_guide_stays_in_the_art_and_short_of_the_opposite_guide`, `canvas::tests::the_edited_symbols_nine_slice_guides_are_drawn_and_dragged_on_the_canvas`. - **Delete Symbol asks first** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [symbol libraries](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Alt-click Delete](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): the Symbols panel's Delete Symbol button and menu item ask "Delete the selected symbol?" with Yes and No for a symbol no instance uses. For a symbol with placed instances they ask with Expand Instances (the instances become ordinary art), Delete Instances and Cancel; either Instances button deletes the symbol in one undo step. Alt-click on the button deletes an unused symbol without asking and still asks about one with instances. A question left open while another document became active deletes nothing. `symbol.delete` itself does not ask. Tests: `panels::symbols::tests::delete_symbol_asks_first_and_alt_click_deletes_an_unused_symbol_at_once`, `the_in_use_question_expands_or_deletes_the_instances`, `a_stale_delete_symbol_question_deletes_nothing`, `the_delete_symbol_questions_show_their_buttons`. - **Symbols › Select All Unused** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), #4918): the Symbols panel menu's Select All Unused shows the symbols nothing places selected (`symbol.unused`): instances in the art (opacity masks included), graph designs, pattern tiles and brush art count, and so do instances in a used symbol's art. Delete Symbol, the button or the menu item, then asks "Delete the selected symbols?" once and deletes those still unused in one undo step (`symbol.delete {names}`); Alt-click on the button deletes them without asking, and a click on a symbol picks just it again. Tests: `tests::brushsym::symbol_unused_lists_the_symbols_nothing_places_and_delete_takes_several`, `panels::symbols::tests::select_all_unused_picks_the_unused_symbols_and_delete_deletes_them`, `dialogs::symbol_delete::tests::several_symbols_get_the_plural_question`. - **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged). - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_keeps_the_center_of_art_away_from_the_origin`, `reset_transformation_ignores_a_stored_size_that_is_not_positive`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_keeps_the_center_of_art_away_from_the_origin`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged). - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol. A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas. @@ -732,6 +785,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Type › Highlight Alternate Glyphs (on by default: pink behind OpenType substitutions on screen — ligatures, contextual forms; Document Setup's Highlight Substituted Glyphs stays separate; `prefs.set`, #394). About 14 stored options still do nothing yet (the rest of #394: Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Touch Workspace). - The desktop app keeps the Color panel's Show Options or Hide Options between sessions, and `ui.colorPanel` sets its mode and options as its panel menu does. - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Units › East Asian Type (the bullet below). Then User Interface › Open Documents As Tabs (see Window › Arrange). Three stored options still do nothing yet (the audit below). + - Preferences › General › Scroll Sideways with Cmd/Ctrl + Wheel (on by default; dimmed while Zoom with Mouse Wheel is on): while Zoom with Mouse Wheel is off, Cmd/Ctrl-wheel scrolls the canvas sideways instead of zooming. Alt + Cmd/Ctrl-wheel and a pinch on a Mac trackpad, on a touch screen or in the web build still zoom about the pointer, and once a trackpad gesture holds Alt, it zooms until it ends, also after Alt is let go. - Preferences › Type › Highlight Substituted Fonts (on by default) paints pink behind text drawn in a substitute for a missing font in every document, and turning it off removes that highlight everywhere (`prefs.set highlightSubstitutedFonts`); Document Setup keeps Highlight Substituted Glyphs ([help](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/edit-text-without-replacing-missing-fonts.html)). - Preferences audit (2026-10-30, #394): every Preferences dialog option is read by shipped code, by its field or its key, except Plug-ins & Scratch Disks › Primary and Secondary, and Clipboard Handling › Legacy vector clipboard and its mode, which are stored only (Units › East Asian Type and User Interface › Open Documents As Tabs, stored only on the day of the audit, act now: East Asian Type since 2026-10-31, Open Documents As Tabs as the Window › Arrange bullet says); [gaps.md G12](gaps.md#g12-preferences-and-panels-that-dont-act-yet) says what each waits on. - Units › East Asian Type (#394, `unitsAsianType`): Points by default; it offers every unit and Q and Ha, a quarter of a millimetre each (0.709 pt: the [Illustrator Scripting Guide](https://ai-scripting.docsforadobe.dev/scripting/measurementUnits/); `q` and `h` are the field suffixes [InDesign's notation table](https://helpx.adobe.com/in/indesign/desktop/layout-and-grid-tools/rulers-and-measure-tools/change-ruler-measurement-units.html) lists), and only it lists them: rulers, Document Setup and the other Units preferences keep their ten. The public pages do not say which fields follow it, so no field uses it yet and that waits for a source. The model stays in points; any length field reads `13q`/`13h`. Evidence: `q_and_ha_are_a_quarter_millimetre`, `east_asian_type_unit_*` (`crates/engine/src/tests/units.rs`). @@ -741,7 +795,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - With Real-time Drawing and Editing off, a dragged selection whose outline points and evaluated objects come to more than 100,000 shows its moved bounds instead, as the selection at rest does (#525, #2460; `real_time_drawing_off_draws_the_bounds_of_a_selection_too_big_to_outline`, `real_time_drawing_off_draws_the_bounds_of_a_group_too_big_to_outline`, `real_time_drawing_off_draws_the_bounds_of_a_repeat_with_too_many_objects`). - With Real-time Drawing and Editing off, a move or handle drag of a group, blend, envelope, repeat or intertwine draws the outline of each of its objects instead of one union of them, as VectorCraft's own rule (`real_time_drawing_off_outlines_a_dragged_group_member_by_member`, `real_time_drawing_off_outlines_a_resized_group_member_by_member`, `real_time_drawing_off_outlines_a_dragged_blend_step_by_step`, `real_time_drawing_off_outlines_a_dragged_envelope_object_by_object`). A group with a Pathfinder effect draws the area the effect makes, a gradient mesh draws its mesh lines, and a symbol instance draws its bounds (`real_time_drawing_off_outlines_a_dragged_pathfinder_group_by_its_area`, `real_time_drawing_off_outlines_each_mesh_of_a_dragged_repeat_once`, `real_time_drawing_off_outlines_a_dragged_symbol_instance_by_its_bounds`). - Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423). - - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). A dialog opened from a menu takes the keyboard focus every time it opens, not only the first (#1367), and one whose first field starts greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the focus to that field once it is enabled (#1395). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. + - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). A dialog opened from a menu takes the keyboard focus every time it opens, not only the first (#1367), and one whose first field starts greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the focus to that field once it is enabled (#1395). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the hovered field without requiring focus (#485, #5949: a notch a step; the panel under it then stays put, and a disabled field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. - While Alt (Option on macOS) is held, the Flare Tool Options' Cancel button reads Reset, which puts every option back to the Flare tool's default and leaves the dialog open (`ui.dialogReset`). - A plain step of a number field (Up/Down or a wheel notch) goes to the next whole unit in its direction and a Ctrl/Cmd step goes to the next tenth, so 5.1 mm steps up to 6 mm and down to 5 mm (#2881). A Shift step adds or takes ten and keeps the fraction. - In the number, text and color fields of the panels and the Control bar, Shift+Enter (Shift+Return) applies the value and keeps the field active with its value selected; a text box of several lines takes it as a line break. In a dialog, Shift+Enter applies the value and confirms the dialog, as Enter does. @@ -755,6 +809,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Shift+Tab (`window.panels`) shows and hides the dock and the floating panels and leaves the toolbar and the Control bar as they are; hiding the panels with Tab or Shift+Tab also closes a panel popped out of the icon column. - The second Tab (`window.dock`) shows again only the bars the first Tab hid, after a restart too: a Control bar that was off stays off, a toolbar hidden with Window › Tools stays hidden, and panels shown with Shift+Tab in between stay shown. - The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols activate a background tab on the first press, hide its expanded dock or floating group when that tab is in front, and restore the group with all its tabs together on the next press; in an icon column they open and close the flyout while keeping the icons. A reassigned chord keeps that behavior, and Window > a hidden sibling restores the group. + - On Windows and Linux, F2, F3 and F4 run Cut, Copy and Paste alongside Ctrl+X, Ctrl+C and Ctrl+V, and in the web build they do so everywhere except macOS. The Edit menu keeps the Ctrl shortcuts, and Keyboard Shortcuts lists both keys of each row. On macOS only Cmd+X, Cmd+C and Cmd+V are the defaults. F2, F3 and F4 do not run these commands in a focused text field or while the Type tool edits text. A saved shortcut set that assigns one of these keys to another command keeps that assignment. Public reference: [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html). + - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected). - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). In a frame of threaded text, Select All selects the whole story, highlighted in every frame: Copy and Cut take all of it, typing or Delete replaces it as one undo step, and a fill, a size step or a Character or Paragraph panel change applies to every frame's text (VectorCraft's own behavior; `textedit::select_all_in_a_threaded_frame_selects_the_whole_story`, `textedit::typing_over_the_whole_story_replaces_every_frames_text_in_one_step`). - With the whole story of threaded text selected by Select All, Remove Line Breaks, Remove Paragraph Breaks and Type › Bullets and Numbering › Convert to Text act on the text of every frame as one undo step, and the story stays selected after Convert to Text; a selection inside one frame changes only that frame (VectorCraft's own behavior; `textedit::remove_breaks_takes_the_whole_story_that_select_all_selected`, `textedit::convert_to_text_takes_the_whole_story_that_select_all_selected`). - With the whole story of threaded text selected by Select All, the Character, Paragraph, OpenType, Tabs and Text Styles panels, the Justification dialog and the font menu change the frames that are in the edited frame's thread at the time of the change, locked and hidden frames included. A frame that has left the thread is not changed, and a frame added to the thread is (VectorCraft's own behavior; `panels::character::tests::story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves`, `story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined`, `story_changes_stay_in_the_edited_frame_once_its_thread_is_removed`, `story_changes_take_in_locked_and_hidden_frames`, `panels::tabs::tests::indent_markers_after_select_all_keep_each_story_paragraphs_indents`, `dialogs::justification::tests::justification_dialog_follows_the_story_thread_at_ok`). @@ -777,6 +833,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Shortcut sets other apps save as text (`.kys`) open in Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts › Import… in the desktop app, or from a drop on the window. It matches each entry to a tool, command or panel by name and decodes the file's key and modifier codes where VectorCraft's default shortcuts agree on them; the user answers the rest, and Apply fills the Keyboard Shortcuts dialog's working set. The answers are kept with the preferences for the next import (`shortcutSet.read`, `importMatches.*`). - Edit › Keyboard Shortcuts › Export Text… saves the set shown in the dialog as a UTF-8 text file, as the reference app's [Export keyboard shortcuts list as a text document](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/export-keyboard-shortcuts-list.html) page describes (`shortcuts.exportText {path?}`). The layout is VectorCraft's own: the set's name, then the Tools and Menu Commands rows in the dialog's order, each with its name, a tab and its shortcuts as the dialog shows them, and nothing after the tab for a row with no shortcut. Tests `shortcut_editor::tests::export_text_*` and `tests::picks::keyboard_shortcuts_export_text_writes_the_working_set_as_text`. - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. + - On Linux and the BSDs, Preferences › User Interface › VectorCraft Title Bar on Every Desktop (off by default) uses the app bar as the title bar from the next start in the sessions where the desktop would draw the window's frame (X11, Wayland compositors with server-side decorations such as KDE Plasma, a window opened through XWayland). System Title Bar and `VECTORCRAFT_TITLEBAR` take precedence over it, and `VECTORCRAFT_TITLEBAR` also takes precedence over System Title Bar for the run it is set in. Only the values `app`, `system` and `desktop` take precedence (case-sensitive, surrounding spaces ignored); any other value, such as `unknown`, is ignored and does not override System Title Bar. - Edit › Keyboard Shortcuts lists items of the menu bar and panel menus, as the reference app's [Customize keyboard shortcuts](https://web.archive.org/web/20221205131209id_/https://helpx.adobe.com/illustrator/using/customizing-keyboard-shortcuts.html) page describes for its Menu Commands. A menu command with no default shortcut that needs a value a key press does not give, such as File › Document Setup › Units (`document.setUnits {units}`), is listed only while the user has changed its shortcut. A command stays listed when a menu bar item runs it without a value or when it opens a dialog run without one, as the Variables panel's commands do. - A shortcut on the key left of 1 runs whatever character that key types, and Edit › Keyboard Shortcuts, the menus in the window, the command palette and tool tips show it as that character once the key has been pressed: `"` on a Turkish Q layout, `~` on a US layout as before. Shortcuts saved as `~` keep working (#4559). - On Windows a maximized window stays inside the work area with the taskbar on any edge (#3242, #3723). @@ -794,6 +851,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Select by Artboards (Object › Plug-ins › Selection Tools, a VectorCraft addition: #5525, #5548, #5549) selects the objects overlapping, fully inside or fully outside one or more chosen artboards (`addon.select.byArtboards {artboards, relation}`). Overlapping is All on Active Artboard's test, shared as `select::on_artboard`; Fully inside means within one artboard, edges included; Fully outside means on none. It looks at what Select All takes, so hidden, locked and locked-artboard art stays out. Its dialog lists each artboard's number, name and size in the document's units, filters by name, chooses rows with click, Shift-click and Cmd/Ctrl-click, and fits a row's artboard in the window on double-click (`view.goToArtboard`). Tests: `crates/engine/src/cmd/addons/select/tests.rs`, `crates/ui-egui/src/addons/select/tests.rs`, `crates/engine/tests/command_sweep.rs`. Select by Link Status (Object › Plug-ins › Selection Tools and the Links panel menu, a VectorCraft addition: #5526) selects the image objects and placed documents whose link is linked (its file found unchanged), embedded, missing or modified, as `links.list` reports it (`addon.select.byLinkStatus {status, ids?, add?, zoom?}`); Select Linked, Embedded, Missing and Modified are off when no image has that status and add to the selection with Shift. Objects that are hidden or locked, inside a hidden or locked object or layer, on a template layer, on a locked artboard, or outside the isolated group are left out (the `select::is_selectable` test) and returned with the reason, and the status bar says how many, in plural forms of every catalog. Select by Link Status… lists each image and placed document with its name, status and format, searches by name, filters by status, picks rows with checkboxes and All / None, and zooms to the selection with the existing Zoom to Selection (`addon.arrange.zoomSelection`). Missing-link markers on the canvas are #5547. Tests: `crates/engine/src/cmd/addons/select/tests.rs` (`by_link_status`), `crates/ui-egui/src/addons/select/link_status/tests.rs`, `crates/engine/tests/command_sweep.rs`. A marquee selects a clip group, or an object on a layer with a clipping mask, only when it reaches the area inside the clipping path, and it measures the art only within that path's bounds. Within the bounds of a mask that is not a rectangle, it can still select art that the mask hides. Direct Selection's marquee still selects the hidden anchor points inside it (#2345). + When a clip group, a layer with a clipping mask or a single object in one is isolated, the Selection, Direct Selection and Group Selection tools also select the art that the mask hides, by a click or a marquee, and a drag from that art moves it (#3748). In isolation mode the canvas Unlock submenu also lists that art when it is locked. Outside isolation mode a click selects clipped art only inside the mask. A marquee selects a Shaper Group only when it reaches the art the group shows, not the original shapes kept under it, and Direct Selection's clicks and marquee do not select those shapes' anchor points. In Construction mode a marquee selects the original shapes, and Direct Selection selects their anchor points. While an opacity mask or a pattern is edited, a Shaper double-click on a Shaper Group in the mask or pattern art selects the original shape under the pointer without entering Construction mode, the Shaper Group's mode widget does not show, and a Shaper scribble, Release and Expand keep the editing layer isolated. A marquee selects an Intertwine group only when it reaches one of the group's members, as for a plain group, and it selects a repeat only when it reaches one of the copies the repeat draws. Direct and Group Selection's marquees select the anchor points of an Intertwine group's members, not the group, and Group Selection's E marquee selects the members inside it. @@ -829,9 +887,11 @@ 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. + 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 and Decay as the amount each wind decreases. An 80% spiral has 80% of its outer radius after four segments; newly drawn spirals with the same settings wind more broadly than in earlier VectorCraft builds. 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. + Escape while an arc, spiral or grid is dragged out cancels it and puts back the options its keys, Alt and Ctrl/Cmd changed (slope, open or closed, base axis, segments, decay, dividers and skews), so the next drag and the tool's options 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. - **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes). While Alt is held, the Scissors tool works as the Add Anchor Point tool ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): a click on a segment adds an anchor point without cutting (`path.insertAnchor`), and the pointer shows the Add Anchor Point cursor. When Alt is let go, a click cuts again. Alt-drag with the Knife cuts in a straight line, and Shift+Alt-drag cuts at 45° or 90°, as [Cut objects using the Knife and Scissors tools](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) and [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) describe. Alt counts only when held at the press; the dashed line runs from the press point to the pointer, stays straight after Alt is let go, and release sends `path.knife` with those two points. Alt pressed after the press leaves the cut freehand. Shift+Alt keeps the endpoint at 45° steps from the horizontal and vertical, whatever the Constrain Angle. @@ -860,7 +920,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them. - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, and the paint, weight and opacity of a mark's top visible fill or stroke row after an Appearance command or the Eyedropper, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View. @@ -947,6 +1007,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Object › Artboards › Fit to Selected Art resizes the active artboard (`artboard.fitToSelection {index}`). It used to resize artboard 1 whichever artboard was active. When an action plays, its Fit to Artwork Bounds and Fit to Selected Art steps that name no artboard resize the artboard that is active then. Object › Artboards › Fit to Artwork Bounds and the Artboards panel menu's Fit to Artwork Bounds fit each selected artboard (those the Artboard tool has selected, else the active one) to the art on it, as the public [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) page describes (`artboard.fitToArt {indices}`). An object partly on an artboard counts for each artboard it overlaps, and an artboard with no art on it keeps its size and place. They used to fit the active artboard to all the art in the document. The Artboards panel menu's Duplicate Artboards copies each selected artboard (those the Artboard tool has selected, else the active one) with its art, in one undo step, and the copies are then selected, as the public [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) page describes. With the Artboard tool, Copy and Cut take every selected artboard with its art, as the public [Cut, copy, and paste artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/cut-copy-and-paste-artboards.html) page describes, and Paste brings them all back in one undo step (`artboard.duplicate`, `artboard.copy` and `artboard.cut` with `indices`). The copies keep their places relative to each other, together right of the last artboard. All three used to act on the active artboard only. + A replay of the journal repeats a Cut or Copy made with the Artboard tool on the artboards it took (`edit.cut` and `edit.copy` record `selectedArtboards` and `art`), whichever tool is chosen in the new session and whatever is selected there. A recorded action follows the tool chosen when it plays. The replay used to act on the objects selected then, or stop with nothing selected. This rule is VectorCraft's own. With the Artboard tool, the canvas context menu lists the entries of the public [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) page in its order: Cut, Copy, Paste at the pointer, Undo, Redo, New Artboard, Duplicate Artboards, Rename (`artboard.rename`), Delete Artboards, Delete Empty Artboards, Select All Artboards (`artboard.selectAll`), Lock Artboards and Unlock Artboards, Rearrange, Fit to Artwork Bounds, Switch Orientation (`artboard.switchOrientation`), Artboard Options and Export. A right-click on an artboard that is not selected selects it first. The Artboards panel menu's New Artboard adds the artboard in one undo step and makes it the active one, as the panel's button, the Control bar and Properties do; with the Artboard tool it is then the only one selected. This is VectorCraft's own rule. The menu item used to leave the active artboard where it was. With the Artboard tool, Paste and Paste in Place make the first pasted artboard the active one (the navigator's and the Artboards panel's), and Cut makes the artboard before the first cut one active (the new first artboard when artboard 1 was cut). A command that chooses another document (`document.activate`) starts the tool on that document's active artboard, as choosing its tab does, and File › Revert keeps the active artboard and starts the tool on it. Paste used to leave the active artboard where it was while the tool had the pasted artboard active, and Cut left the active artboard on the artboard after the cut one while the tool had the one before it. @@ -964,12 +1025,14 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Geometry and effects:** - **Open-path anchor Delete:** Delete/Backspace removes selected anchors and their adjoining segments, leaving drawable runs as separate open subpaths of the original object. Surviving curves and appearance stay intact; one Undo restores the document and selection, and a second Delete removes the selected remainder. Explicit Remove Anchor Points still reconnects/refits. Four core regressions, one full-frame Delete/Backspace regression and three existing removal/segment controls pass, covering these distinctions, multiple deletions, transformed geometry and mixed whole-object selection. Behaviour references: the legacy public [Illustrator reference guide, Add or delete an anchor point](https://helpx.adobe.com/content/dam/help/en/pdf/illustrator_reference.pdf) distinguishes keyboard deletion from dedicated removal; the current [Add or remove anchor points](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/add-or-remove-anchor-points.html) describes the dedicated tools. Other G11 gaps and scores/hours remain unchanged. - Pathfinder (10 exact curve booleans; single-result shape operations retain source names and SVG IDs, #1293), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. With one group selected alone, the Pathfinder panel's buttons work on its members (among other selected objects a group still acts as one shape), and a greyed-out button says what it needs (#1450; behaviour reference: [combine objects using Pathfinder effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/combine-objects-using-pathfinder-effects.html); one plain path selected alone stays disabled, which the documentation doesn't settle). Split Into Grid takes the number, height or width, gutter and total of the rows and of the columns, Add Guides and Preview, and several selected paths become one grid painted as the topmost one (#1462, `object.path.splitIntoGrid`; behaviour reference: [divide or split objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/divide-or-split-objects.html); a typed height or width keeps the gutter and changes the total, our choice). Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635). + Outline Stroke, Expand's Stroke option and Flatten Transparency's Convert All Strokes to Outlines apply the object's visible geometry effects (Offset Path, Zig Zag and the others) to the path, and then apply each stroke's own geometry effects before outlining that stroke, as the canvas draws it. The filled outline of a stroke without a brush keeps the stroke's other effects, such as a drop shadow. Outline Stroke and Unite remove the overlaps of a long, low path with the sweep turned along it: when the estimated number of segments the sweep holds at once is under two thirds as large for the path turned a quarter turn (paths of 2,048 segments or more), the overlaps are removed on the turned path and the result is turned back, which moves no point by rounding. The lookups of the input curve that a piece of the result lies on, and of the input anchors that are kept, can search along y instead of x: once lookups with more than 64 candidates along x have counted 16 for each input curve or anchor, a lookup searches along y when it finds fewer candidates there (under a quarter as many for curves), and where that has rarely happened it measures along y in only one lookup of 32. On one open path of 200,000 anchors in rows of 1,000, Outline Stroke takes 17.3 s of processor time instead of 31.3 s and Unite 2.9 s instead of 6.5 s in a release build. A turned result has as many subpaths as the result without the turn, and the area where the two differ is no larger than the refit precision times the outline's length (`crates/pathops/src/sweep_axis.rs` tests). - Divide uses open lines, bent paths and curves as cutting edges (#1307), preserving the frontmost filled face's appearance and holes. Engine tests cover implicit closure of filled open contours and unrelated objects; native automation confirms independent piece movement, one Undo/Redo, canvas/window captures and an exact saved-document read by a separate CLI process. The shared planar map keeps a segment cap (65,536 since #937, was 4,096) and its filled-area fallback; other Pathfinder operations are unchanged. - Type that Transform effects only move and copy (no scale, rotation, reflection or Random) exports as type in each place, and PDF export writes every copy as selectable, searchable text, when its characters are in solid colors without underline or strikethrough, the type object has no fills or strokes of its own, no inline graphics and no perspective, its transform does not scale or shear stroked characters, and the copies do not overlap. When the effect is on the type itself, the type can also have transparency, an opacity mask and raster effects. When the effect is on the group or layer holding the type, the type has no transparency or effects of its own and is not in a clipping path, the group or layer has no fill, stroke or Pathfinder effect of its own, and no group between them has a fill, stroke, Pathfinder or geometry effect. Type still exports as outlines when the effect stops its copies early, or when the copies would number more than 10,000 or take more than 20,000,000 bytes of document data, and so does other type under a geometry effect. - Extrude & Bevel and Inflate treat strokes as outlined: each visible stroke builds a solid from its outline in its own paint beside the top fill's ([three-d.md](three-d.md#fills-and-strokes)). - Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715). - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). + - Export 3D Object (Asset Export's menu): an object with Extrude & Bevel, Revolve, Inflate or Rotate becomes a 3D asset whose Format is USDA (the default), USDZ, glTF or OBJ, and Export writes each of its solids as a mesh with a material in its paint's color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`; [export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558). Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578). `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). On the canvas, in Expand Appearance and in exports, Effect → Pathfinder uses the glyph outlines of type in the group, and results painted like the type keep its opacity and blend mode. - Cmd/Ctrl+4 (`pathfinder.repeat`) repeats the last successful Pathfinder panel operation, direct `object.pathfinder.*` command or `command.batch` step (including Actions playback) on the current selection; the panel menu names it. `action.batch`, Trap, compound-shape edits and live effects do not set history. Public references: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html). @@ -1002,11 +1065,13 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Manual kerning on inline graphics:** a manual kerning value on an inline graphic's character spaces it from the next character in reading order by the same distance as on a letter of its run's size, in left-to-right, right-to-left and vertical type; Auto and Optical kerning still apply no font pairs to it. This is VectorCraft's own rule. Test: `manual_kerning_on_an_inline_graphic_spaces_it_like_a_letter`. - **Recent font pickers (#3515):** The shared Character, Properties, Glyphs and Find Font family picker lists recently applied families first, once each, in most-recent order and limited by Preferences › Type › Number of Recent Fonts. Search and filters apply to both sections; unavailable families are omitted. Section headings cannot be selected or previewed. Confirmed character-range font edits update the same saved history; previews and failed edits do not. Requirement: [issue #3515](https://github.com/storytold/vectorcraft/issues/3515). - **Find Font: Replace With Font From:** a dropdown under Replace With Font chooses the list the replacement font comes from: Document lists the families the document uses, once each; Recent lists the recently applied families that are installed (fonts applied with Find Font's Change and Change All included), newest first, as many as Preferences › Type › Number of Recent Fonts allows; System, the default, lists the installed families available in the font menu. The dialog opens on System with Source Sans 3 chosen, or the font menu's first family when Source Sans 3 is not installed. Choosing a list keeps the chosen font when the list has it and it is installed; otherwise Document and Recent choose the list's first installed font, and System chooses Source Sans 3 or the font menu's first family. Change and Change All are unavailable when the list has no installed font (Recent before any font was applied) and when the chosen family is not installed or is not in the shown list. Behavior reference: [Find and replace fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-replace-fonts.html) (public documentation). Tests: `crates/ui-egui/src/find_font.rs`. +- **Select Text by Font: scope (a VectorCraft addition, #5529):** `select.font` takes `scope`: `"document"` (the default, as before), `{artboards: [index, …]}` or `{layers: [id, …]}` (sublayers included). On an artboard means what All on Active Artboard takes (the bounds overlap it or the center is on it; one shared test, `select::on_artboard`); hidden and locked text stays out as before; a symbol instance counts where the instance is. Type › Find Font…'s Find In dropdown passes the active artboard or the selected layers (else the current layer). Tests: `crates/engine/src/cmd/fonts.rs`, `crates/ui-egui/src/find_font.rs`, `crates/engine/tests/command_sweep.rs`. - **Mouse wheel over the font family field (#5253):** In the Character and Properties panels and the Control bar, turning the mouse wheel over the closed font family field applies the next family to the selected type (the previous one when turned up), keeping the style when the family has it, else its closest. Each notch is one undo step; leaving the field or Escape keeps the family, and Undo takes it back. The steps use the font menu's order at the first turn and keep it until the pointer leaves the field or the text selection changes, so recent-font updates do not reshuffle later turns. That order follows the menu's saved font filters but ignores its search text, puts recent families first, and stops at the first and last family. A turn with a modifier key is left to the panel and the canvas. Behavior reference: the [Illustrator CS6 reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf#page=10) describes editing hovered dropdown controls with the mouse wheel; [Find and apply fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-apply-fonts.html) names the family field's places. Applying it to the font field, the order, the ends and the undo steps are VectorCraft's own rules. Tests: `crates/ui-egui/src/tests/font_menu.rs`, `crates/ui-egui/src/font_menu/tests.rs`, `crates/ui-egui/tests/font_wheel.rs`. -- **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, Text Flow by rows or by columns (#4978), vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`), and an anchor of the path of type on a path outside perspective, the type reflowing along it (`text.reshapePath`, #3980). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. The Character panel's Variable Font sliders set each axis (see Fonts). Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). Cmd+Shift+C (Align center) works on Windows and Linux too (#1940). A double-click on area type's bottom middle handle turns on Auto Size (#1940). Print and Print Preview warn about the area type and type on a path that prints without the text that doesn't fit, and give their ids. A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). +- **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, Text Flow by rows or by columns (#4978), vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`), and an anchor of the path of type on a path outside perspective, the type reflowing along it (`text.reshapePath`, #3980). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. The Character panel's Variable Font sliders set each axis (see Fonts). Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). Cmd+Shift+C (Align center) works on Windows and Linux too (#1940). A double-click on area type's bottom middle handle turns on Auto Size (#1940). Print and Print Preview warn about the area type and type on a path that prints without the text that doesn't fit, and give their ids. A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches back to the tool that was active before type editing began (kept while clicks move editing to other text), or to the Selection tool when there was none (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html). The Every-line Composer composes a paragraph whose characters differ in line metrics (type size, font, leading or vertical scale, or an inline graphic taller than the text) in rectangular area type, rows and columns included. In area type of another shape, or with text wrap, such a paragraph is broken with the Single-line Composer. The Character panel's Small Caps, Superscript and Subscript buttons and the panel menu's items of the same names turn small caps and the superscript or subscript position on and off for the selected type, or for the text the Type tool has selected (`text.setFormat` or `text.setRangeStyle` `{smallCaps, position}`), in Document Setup's proportions. Turning Superscript on turns Subscript off, and turning Subscript on turns Superscript off. A button or item shows as on when the first selected character (at a caret, the character before it) has its attribute. + The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged. The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`. The OpenType panel's Position menu lists Default Position, Superscript, Subscript, Numerator and Denominator, as [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) describes; Superscript, Subscript, Numerator and Denominator set `sups`, `subs`, `numr` and `dnom` for the selected type and turn the other three off, and Default Position turns all four off. The menu shows Mixed when the characters it acts on differ, and an entry chosen then applies to all of them. These two rules are VectorCraft's own, as is setting the feature on type whose font lacks the glyphs, which then draws unchanged. The panel's menus and checkboxes change only their own features on each selected character, so characters keep their other OpenType features (`text.setStyle` and `text.setRangeStyle` `{featureChanges}`). Tests: `panels::opentype::tests`, `features::tests::numerators_and_denominators_are_kept_and_shaped`. The Glyphs panel's Show menu and panel menu have Alternates for Current Selection, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes: the grid lists the alternate glyphs of the character selected with the Type tool, and a double-click draws that character with the glyph as one undo step (`text.glyphAlternates`, `text.setGlyphAlternate`). The alternates come from the font's `GSUB` single and alternate substitutions of `aalt`, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99` and a few other alternate features, each glyph once. The text keeps the character, native files keep the choice with the face it was made in, and type set in another font or version draws the default glyph. The list uses the font of the character's text even when the panel browses another font, and the panel shows a note when the character has no alternates, its font is missing or no character is selected; these rules are VectorCraft's own. Tests: `panels::glyphs::tests`, `tests::alternates`, `tests::textedit::a_glyph_alternate_replaces_one_character_through_undo_and_reopen`. @@ -1029,27 +1094,34 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency stop building a group's flat regions at the first region past the raster/vector balance's limit (10^(balance/20), rounded down) and rasterize the whole group without building the remaining regions. Building every region gives the same flattened art and the same files. With Clip Complex Regions on, a group builds every region and its image is clipped to them. New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. + Type on a Path Options › Align to Path (option names as in [Create type on a path](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-type-on-a-path.html)) moves each character by its own font, size and vertical scale: Ascender runs the top of the font's em box along the path (the ideographic em box that its `BASE` table, its typographic or `hhea` box or its ideographs give, else an em box centered on the cap height), Descender runs the bottom of the font's bounding box along it, and Center runs a line at half the height of the top of the font's H above the baseline along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. These distances are VectorCraft's own values (`tests::on_path_align_runs_each_glyph_by_its_own_metrics`, `tests::on_path_align_finds_the_em_box_of_each_kind_of_face`, `tests::on_path_align_in_vertical_type_moves_half_an_em`). 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. + The frames of threaded text share one writing direction: Type › Type Orientation (`type.orientation.vertical`, `type.orientation.horizontal`) in one frame sets every frame, threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and the story flows through every frame in the thread's direction. 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 Reset Panel (`text.setStyle`, `text.setFormat` and `text.tabs.clear`) gives the selected type's paragraphs, or the paragraphs the Type tool's selection touches, Auto alignment, no indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Roman Hanging Punctuation off, no kashidas and no tab stops, as one undo step. The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options. 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. The Character panel menu's Kashidas switch (with the Indic options; `text.setFormat` / `text.setRangeStyle {kashidas}`) allows Kashidas on selected characters, on by default for new and old text. A pair of joined letters receives one only when both characters allow it; turning one off leaves other eligible pairs in the word available. Character styles and the native file keep the switch. [Public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/justify-text-using-kashida-and-hyphenation.html) documents the menu control and the separate paragraph length choices; the default and pair rule are VectorCraft choices. - The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. The Bullets and Numbering dialog, levels and list detection while typing are not built yet. + The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. The Bullets and Numbering dialog, levels and Convert to Text are not built yet. The Paragraph panel's Numbered List button, Type › Bullets and Numbering › Apply Numbers and the context menu's Apply Numbers (`text.setFormat {list, number}`, `type.applyNumbers`) make paragraphs a numbered list with the documented presets (1, A, a, I, i, 01, 001, and None, which turns the list off; [Bullets and numbering](https://web.archive.org/web/20250807202418/https://helpx.adobe.com/illustrator/using/bullets-numbering.html), [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): each paragraph starts with its number and a period, counted through the list paragraphs of the story and its threaded frames, with the text and indents as in a bulleted list; letters go on from Z to AA, and roman numerals go on from III to IV, an empty paragraph is not numbered, and a paragraph in no list starts the count again at 1. + More Options in the Bullets and Numbered List presets (`ui.bulletsAndNumbering`) opens the Bullets and Numbering dialog ([Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): a Bullets tab and a Numbers tab with the presets, and the Position group's Alignment, Left Indent and First Line Indent, shown on the type while the dialog is open; OK applies them with `text.setFormat` (Alignment is its `listAlign`) as one undo step, and Cancel puts the type back. Alignment Left starts the marker at the first line's start, and Right ends it where the text starts. The cited page does not say where the marker sits for each choice, so these meanings are VectorCraft's own rule. Paragraph styles and the native file keep the alignment. + Tab and Shift+Tab with the caret at the start of a list paragraph (`text.setFormat {listLevel}`), and the Bullets and Numbering dialog's Level field (OK sends it as `listLevel`), move the paragraph down and up the list's nine levels ([Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html), [Bullets and numbering](https://web.archive.org/web/20250807202418/https://helpx.adobe.com/illustrator/using/bullets-numbering.html)): each level past the first sets the paragraph 18 pt further in, each level counts its own numbers from 1, and a paragraph back at a shallower level goes on with that level's count, through threaded frames too. Elsewhere Tab inserts a tab as before; one Undo takes a level change back, and paragraph styles and the native file keep the level. Convert to Text writes each level's numbers and keeps a deeper paragraph's indent. Type › Bullets and Numbering › Convert to Text (`type.convertListToText`, `text.convertListToText`) makes the bullets and numbers of the list paragraphs editable characters of their text ([Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html), [December 2022 release notes](https://web.archive.org/web/20260203094741/https://helpx.adobe.com/illustrator/using/whats-new/2023-1.html)): each marker becomes its characters and a tab at the start of its paragraph, in the marker's font, size and color, the list is turned off, and a left tab stop at the left indent keeps the text after the tab where it started; threaded type keeps the numbers its story counted. While no type is being edited, a press with Shift held makes new type with the horizontal or vertical counterpart of the Type, Area Type or Type on a Path tool chosen, and a press with Alt (Option) held makes it with Type on a Path in place of Area Type or the reverse, in the chosen tool's writing direction; with both keys held, both switch ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). The pointer shows the tool the press acts as, and the tool chosen stays selected. - The Type tool starts a list from the characters [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html) names (`text.startList`): a space typed on its own after `1.`, `1)`, `A.` or `A)` at the start of a paragraph in no list makes the paragraph a numbered list in 1, 2, 3 or A, B, C, and a space typed after `*`, `#`, `>` or `+` makes it a bulleted list with the filled dot. The typed characters are removed, and one Undo puts them back as text with no list. The same characters later in a paragraph, and pasted list characters, start nothing. + The Type tool starts a list from the characters [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html) names (`text.startList`): a space typed on its own after `1.`, `1)`, `A.` or `A)` at the start of a paragraph in no list makes the paragraph a numbered list in 1, 2, 3 or A, B, C, and a space typed after `*`, `+`, `#` or `>` makes it a bulleted list with the filled dot, the hollow circle, the filled square or the dash (a VectorCraft choice: the page does not say which bullet each character gives). The typed characters are removed, and one Undo puts them back as text with no list. The same characters later in a paragraph, and pasted list characters, start nothing. PDF export writes bullets and numbers as text, in the glyphs and at the places the canvas draws them, in area type, type on a path, right-to-left paragraphs and threaded frames, and a PDF viewer selects, copies and finds them; a marker glyph that stands for no character is drawn as a path. + SVG export writes type on a path with the Skew, 3D Ribbon, Stair Step or Gravity effect as outlines, with a warning, so the file draws the type as the canvas does. The Character panel menu's Character Direction (with the Indic options; `text.setFormat {charDirection}`, `text.setRangeStyle {charDirection}`) sets the selected characters left to right or right to left inside their paragraph, as a Unicode bidi embedding: a date set Left to Right keeps its order in right-to-left text, and a word set Right to Left keeps the punctuation after it on its left. Default (the default) leaves the characters to the bidi algorithm; character styles and the native file keep the setting. In vertical type, the Tabs panel names its alignment buttons Top-Justified Tab, Center-Justified Tab, Bottom-Justified Tab and Decimal-Justified Tab, in their tool tips and for screen readers, and labels the position field Y. In horizontal type the buttons keep the names Left-Justified Tab and Right-Justified Tab, and the field keeps the label X. The Tabs panel's ruler is numbered in the General unit with the canvas rulers' label steps and a tick every tenth of a step. With the panel menu's Snap to Unit on, or Shift held, a tab stop placed by a click on the ruler or dragged on it lands on the nearest tick (`text.tabs.set`). With Snap to Unit off and Shift not held, a tab stop placed by a click on the Tabs panel's ruler or dragged on it lands on the nearest whole point. A position typed in the X or Y field keeps its hundredths of a point. The Tabs ruler's upper first-line marker and lower left-indent marker show absolute positions and set paragraph indents (`text.setFormat`). Dragging the lower marker keeps the upper position, while Ctrl-dragging it (Command on macOS) moves both; marker drags snap like tab stops, and each release is one undo step. With differing left indents, only the upper marker is shown and its absolute target applies to every touched paragraph. When a drag on the Tabs panel's ruler or a value typed in its X field moves a tab stop past another stop, the moved stop stays selected. - The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again. + The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent (18 pt further in for each list level past the first) when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again. - **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). Interface text in characters the interface's fonts lack, drawn in a bundled Japanese, Georgian or Devanagari face or in an installed font that covers them, sits on the baseline of the Latin text beside it, monospace text included (#5816; `crates/ui-egui/src/ui_fonts.rs`, `crates/ui-egui/src/theme.rs`). Character panel menu › Standard Vertical Roman Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {standardVerticalRoman}`, off by default) stands the selected Latin letters, digits and other half-width characters upright one below the other in vertical type instead of rotating them ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); spaces keep their width, tate-chu-yoko blocks stay across the column, and Character Style Options gives it its own checkbox beside Character Alignment. @@ -1069,6 +1141,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, Document Library keeps neutral tones neutral:** each pixel takes the library color nearest in Oklab with differences in hue and chroma counted three times, so a gray or a muted brown traces as a neutral library color instead of a saturated one of similar lightness; a code-drawn test of gray and brown patches with a black, light and blue library in `crates/trace`. - **Image Trace, source image on hold:** while the eye button next to the Image Trace panel's View is held down, the canvas shows the selected Image Trace objects' source image whatever their View, and shows their View again on release, with no change to the document or the undo history. - **Image Trace, large images:** images over 1500 px on a side trace with a warning in the result and the status bar; the anchor limit is 8,000,000 (2,000,000 on the web), measured at about 90 bytes an anchor (see [quality notes](image-trace-quality.md#large-images)). +- **Image Trace, Ignore Color for the background only:** `"ignoreBackground": true` with Ignore Color leaves out only the areas of the ignored color that reach the image's edge and traces the areas of it that other colors enclose, in Black and White, Grayscale and Color; code-drawn tests in `crates/trace` and `crates/engine`. - **Image Trace, Flat Logo working size:** Flat Logo traces at most 1024 px on a side; a larger decoded raster (the 64-megapixel copy for a bigger image) is averaged down in whole-number blocks first, and the trace's `warnings` name the size it was traced at and the image's own size, in place of the slower-trace and 64-megapixel notes; code-drawn 1024 and 1025 px tests in `crates/trace` and `crates/engine`. - **Image Trace, Flat Logo working size:** Flat Logo traces at most 1024 px on a side; a larger image is averaged down by a whole factor first, and the trace's `warnings` name the size it was traced at and the image's own size, in place of the slower-trace and 64-megapixel notes; code-drawn 1024 and 1025 px tests in `crates/trace` and `crates/engine`. - **Image Trace, Preview:** the Image Trace panel's Preview checkbox, before Trace, is on by default, and a setting change traces the selected Image Trace object again at once. Off, changes wait in the panel until Trace applies them as one undo step, or until Preview is turned on again. Changes stay with the object they were made for while another object is selected. When Preview is turned on with that object not selected, the object is traced with its changes when it is selected again. Preview is kept with the UI preferences, and Reset to Default leaves it. The `imageTrace.preview` command turns it on or off. @@ -1087,6 +1160,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Gradient mesh from an image:** Object › Create Gradient Mesh (`object.mesh.create`) on an embedded image makes a mesh of 1 to 50 rows and columns over the picture's rectangle, placed with the image's transform; each point takes the alpha-weighted average colour and the opacity of the area around it, and Appearance and Highlight apply. A linked image is refused with a message to embed it (#1676). Scores and hours unchanged. - **Several mesh points at once (#4207, #4208, #4170):** the Mesh tool and Direct Selection pick several points of a gradient mesh or mesh envelope: Shift-click adds a point or takes a picked one away, Direct Selection's marquee picks the points inside it (Shift toggles them) and selects their meshes, and its click on a mesh line picks the line's two ends and one inside a patch the four points around it. The picked points show filled; dragging one moves them all as one undo step and the arrow keys nudge them (`object.mesh.movePoints {id, points, dx, dy}` or `{items, dx, dy}`), and a click on one of several picked points picks it alone. A solid fill colour (`paint.setFill`: Color panel, Swatches, Eyedropper) recolours every picked point, and the Transparency panel's Opacity fades them instead of the object, the panel showing their opacity (blank when it differs); `object.mesh.setPointColor` takes `points` or `items`. Public reference: [Edit mesh objects](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-meshes/edit-mesh-objects.html) (Shift selects multiple mesh points; a fill colour colours the selected point), [Macworld, gradient mesh](https://www.macworld.com/article/202693/gmesh2.html) (Direct Selection: a mesh line selects the points at both ends, a patch the points around it, Shift-click adds or removes points), [Peachpit](https://www.peachpit.com/articles/article.aspx?p=1638270) (each mesh point can have its own colour or transparency). Tests: `shift_clicks_pick_mesh_points_that_drag_and_nudge_together` and `direct_selection_picks_mesh_points_by_marquee_line_and_patch` in `crates/tools/src/meshedit.rs`, `picked_mesh_points_take_the_fill_opacity_and_drag_together` in `crates/engine/src/tests/editcolors.rs`, `mesh_points_move_and_colour_together` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. +- **Image Trace, Flat Logo named colors only:** with `logoColors` named, `"logoWhite": false` traces those colors only, so white areas take the nearest of them and no white is added; a named white is kept, and enclosed transparent areas stay holes; code-drawn tests in `crates/trace` and `crates/engine`. +- **Image Trace, Keep Colors:** `"keepColors": ["#rrggbb", …]` traces only the palette colors near a listed color (Abutting: each with the paths the trace of every color gives it; Overlapping: not reaching under the colors left out), in Black and White, Grayscale and Color; code-drawn tests in `crates/trace` and `crates/engine`. - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). A single selected grid repeat shows a handle below the grid for rows, one right of it for columns and one past its bottom-right corner for both, and sliders between its first two columns and rows for the horizontal and vertical spacing; the source keeps its size, each drag is one undo step and Escape cancels it ([public grid-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html), #2691). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Choosing the Blend tool again while it is active (its toolbar button, W or `tool.select`) finishes the blend: the object clicked first is dropped and the next click no longer adds to the blend just made, so the click after it starts a new blend ([Blending objects](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)). Window › Blend exposes easing Type, Ramp and independent color Shift through `object.blend.options`. @@ -1123,7 +1198,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. EPS and SVG write the image's own pixels. - **Missing library files:** a persistent swatch or graphic style library panel whose file is gone at startup reopens and stays in the preferences, showing the file's path with Locate Library… (`ui.libraryPanel.locate`), which reads the file from where it is now into the same panel. - **Swatch library and Recolor Artwork parameters:** the `swatch.library.*` commands, `swatch.resetDefaults`, the graphic style library save, load and add, the `recolor.*` commands and `color.harmony`'s `limitTo` refuse parameters of the wrong form, such as `names: "Grays"`, `limitTo: 5` or `includeImages: "false"`. Errors name the parameter, except that a `limitTo` that is not a string, such as `5`, gets an unknown-library error. A `null` counts as left out except for `recolor.apply`'s `model`, which must be a string when given. - - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike (the built-in Generic CMYK compensates both ways, so RGB black separated to a rich black displays as black again). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). + - **Color Wheel panel (part of #978):** Window › Color Wheel shows the Fill and Stroke proxy beside a hue ring around a square with saturation across and brightness up. It edits what the Color panel's sliders edit (the active proxy's color, the selected gradient stop or freeform point) through the same paint commands, and a drag is one undo step. Alt paints the other proxy, and Shift steps the ring by 45°. A pick goes from display sRGB into the working RGB space; an RGB, CMYK or Lab color keeps its model, and a Gray color or a tint of a global swatch becomes a process color in the document's color mode. A click gives the ring or the square the keyboard focus, and the arrow keys then step the color; both report their names and values to screen readers, and the Color Picker's color field takes the same keys. Scores and estimates are unchanged. + - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike (the built-in Generic CMYK compensates both ways, so RGB black separated to a rich black displays as black again). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB grays separate into light grays in cyan, magenta and yellow only, darker four-color grays and a rich black, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). Plain Comma and Period, and also Shift+Comma and Shift+Period, apply the last solid color and the last gradient to the active Fill or Stroke proxy in VectorCraft ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), "Select color fill mode" and "Select gradient fill mode"). Recent Colors keeps the last 30 solid colors applied, newest first and each once, and the list stays the same when another document is opened or made active; a 31st color drops the oldest ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). The list lasts for the session. In the Color and Swatches panels the chips wrap onto further rows when the panel is too narrow for one row (`paint.recent`). Tests: `recent_colors_keep_thirty_colors_across_documents` in `crates/engine/src/tests/paintproxy.rs`, `all_thirty_recent_colors_wrap_into_a_narrow_panel` in `crates/ui-egui/src/panels/color/tests.rs`. A Cmd/Ctrl-click on the Color panel's spectrum applies Complement and a Cmd/Ctrl+Shift-click applies Invert to the active Fill or Stroke (`paint.complement`, `paint.invert`); with Alt also held, the click recolors the inactive one, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) list them. @@ -1141,8 +1217,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Pasting into hex color fields:** in the hex fields of the Color Picker, Swatch Options, Recolor Artwork, the Color panel, Document Setup, Duotone, Color Themes and Perspective Grid, a click or keyboard focus selects the digits. A pasted code of six digits, with or without `#`, replaces them wherever the caret is. A pasted code of three digits also replaces them, unless the paste holds only those digits and exactly three digits stay; those digits then edit the text in place. A press on another field commits the text as Tab and Enter do. As VectorCraft's own rule, a paste that is not a color leaves the field as it was. Tests: `a_hex_code_pasted_into_the_hex_field_replaces_its_digits` in `crates/ui-egui/src/dialogs/tests.rs`, `define_grid_takes_a_three_digit_color` in `crates/ui-egui/src/dialogs/tests_perspective.rs` and `a_paste_into_a_hex_field_replaces_or_edits_its_digits` in `crates/ui-egui/src/tests/numfields.rs`. - **Color Picker layout (#2008):** a centered draggable header, equal-width actions above aligned HSB/RGB/Hex and Lab/CMYK fields, compact field and slider, and warnings below the preview. No horizontal scrolling; narrow windows stack the sections with vertical scrolling. Tests in `dialogs/color_picker.rs` cover actual pointer/key input, typed values before OK/Enter, Cancel/Escape rollback, one Undo step, mode-size stability, warnings and layout at two UI scales. The screen sampler sits above the field and the upstream two-grid Tab order is retained. AccessKit regressions check the action names and each channel slider's displayed value, units and range. German and enlarged English UI renders were reviewed locally; no reference images are shipped. This is a layout improvement, with no change to scores or remaining-hour estimates. - **Eyedropper sources (#1251):** ordinary and Shift clicks, Shift+Alt appearance append, raster pixels and gradient-stop sampling can read visible locked/template art and sources outside isolation. Sampling leaves the source and selection intact; Alt reverse still uses editable hits, and hidden art stays excluded. Engine/tool/hit regressions cover the editing boundary, color models and one-step Undo/Redo. The general object/image workflow is described in [public HelpX](https://helpx.adobe.com/illustrator/using/tool-techniques/eyedropper-tool.html); the lock/isolation expectations come from the user report and these regressions, without a new full-parity measurement. - - **Eyedropper colors in a CMYK document (#2276):** a CMYK image (a four-component JPEG, or an 8- or 16-bit chunky CMYK TIFF) gives its ink amounts as they are, and an RGB image's pixel or an RGB color given to `paint.setFill`, `paint.setStroke`, `paint.sampleColor` or `swatch.new {color}` (hex input and gradient stops included) is separated through the color settings' CMYK working space and intent, as Document Color Mode converts it. With the default Generic CMYK, RGB 83/170/223 separates to C59 M12 Y3 K0, where the profile-free formula gave C63 M24 Y0 K13. - - **RGB input to other commands in a CMYK document:** type colors (`text.create`, `text.setStyle`, `text.setRangeStyle`), a Shape Builder hex fill, `paint.editGradient` stops, freeform gradient points, gradient mesh points (the Mesh tool gives a new point the fill's color in its own model), the Symbol Stainer's color and the swatch commands (`swatch.new {colors}`, `swatch.newGroup`, `swatch.editGroup`, `swatch.edit`) separate RGB through the color settings as `paint.setFill` does: with Generic CMYK, `text.setStyle {fill: "#53aadf"}` gives C59 M12 Y3 K0. A color a swatch already has keeps its model, and a swatch color given with `mode` is converted into that mode only. + - **Eyedropper colors in a CMYK document (#2276):** a CMYK image (a four-component JPEG, or an 8- or 16-bit chunky CMYK TIFF) gives its ink amounts as they are, and an RGB image's pixel or an RGB color given to `paint.setFill`, `paint.setStroke`, `paint.sampleColor` or `swatch.new {color}` (hex input and gradient stops included) is separated through the color settings' CMYK working space and intent, as Document Color Mode converts it. With the default Generic CMYK, RGB 83/170/223 separates to C62 M19 Y0 K0, where the profile-free formula gave C63 M24 Y0 K13. + - **RGB input to other commands in a CMYK document:** type colors (`text.create`, `text.setStyle`, `text.setRangeStyle`), a Shape Builder hex fill, `paint.editGradient` stops, freeform gradient points, gradient mesh points (the Mesh tool gives a new point the fill's color in its own model), the Symbol Stainer's color and the swatch commands (`swatch.new {colors}`, `swatch.newGroup`, `swatch.editGroup`, `swatch.edit`) separate RGB through the color settings as `paint.setFill` does: with Generic CMYK, `text.setStyle {fill: "#53aadf"}` gives C62 M19 Y0 K0. A color a swatch already has keeps its model, and a swatch color given with `mode` is converted into that mode only. + - **Generic CMYK (SWOP-like):** each ink's 40% and 50% tints gain as [CGATS TR 001](https://registry.color.org/cmyk-registry/cgats_tr_001) gives for SWOP printing, and separations stay within SWOP's [300% total ink limit](https://en.wikipedia.org/wiki/Specifications_for_Web_Offset_Publications). The paper, solid and overprint colors, the black-generation curve and the black point are VectorCraft's own values: RGB black separates to 90% black under neutral cyan, magenta and yellow that fill the ink limit (C75 M68 Y67 K90). - **Swatches made from a shown color in a CMYK document:** the Color panel menu's Create New Swatch, an Alt-click on the Swatches panel's New Swatch button, the New Swatch dialog for a gradient, a Fill or Stroke proxy or the Gradient panel's thumbnail dropped on the Swatches panel (or Alt-dropped on a swatch of its kind), and the Gradient panel's Add to Swatches and Save to Swatches keep each color's model, as the Color Guide's and Color Themes' saved colors do: an RGB object's color gives an RGB swatch, and a gradient's RGB stops stay RGB. - **Sample Color from Screen:** an eyedropper button in the Color Picker and `ui.sampleScreenColor` (which can be given a key in Keyboard Shortcuts) pick a color anywhere on the screen with the system's sampler: `NSColorSampler` on macOS, the EyeDropper API in browsers that have it. The color becomes the fill or the stroke as one undo step, or the Color Picker's color, converted from display sRGB into the working RGB space. Windows, Linux and other browsers have no sampler yet; the button is gray there. - **Stroke visibility (#1253):** adding paint to a previously unpainted zero-width stroke restores the existing 1 pt default (`PaintDefaults::default`, `Appearance::set_stroke`), for the selected appearance item, the new-art defaults and Swap Fill/Stroke. Positive widths are kept; recoloring a painted zero-width stroke keeps its explicit zero. A real Eyedropper-to-new-shape sequence reproduces the zero-width inheritance. Five engine regressions cover creation, defaults, targeted items, preserved widths and one-step Undo/Redo (`crates/engine/tests/it/stroke_visibility.rs`). @@ -1151,7 +1228,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The stop popover a double-click on a stop opens (the Gradient tool's annotator or the Gradient panel) has an eyedropper beside its Color and Swatches tabs: it closes the popover and samples the next colour clicked on the art into the stop, as the Gradient panel's eyedropper does (#4681). Cmd/Ctrl-click on the Gradient panel's Gradient Fill box or on the Tools panel's Gradient button resets the gradient of the active Fill or Stroke to the linear White, Black gradient, as one undo step ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke. - - **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. Points in different models mix in display RGB, and a CMYK document stores the result as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own. + - **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. The canvas, the gradient chips and the SVG, PDF, EPS and EMF writers mix the points in that model too. PDF and EPS write a gradient whose points are all CMYK, or all Gray in a CMYK file, as a CMYK image, which PDF's Output settings convert as they convert any CMYK image. Points in different models mix in display RGB (on the canvas in a CMYK document that shows transparency, as the inks of each point), and a CMYK document stores the result of Expand as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own. - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random, Pressure, Tilt, Bearing or Rotation with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values, Pressure from the first at the lightest pressure to the second at the heaviest, Tilt, Bearing or Rotation; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, Stylus Wheel, and the pen's tilt, bearing and rotation on Windows, native Wayland and the web. Under Tints and Tints and Shades, art in black ink alone goes by its ink amount, and a process or gray stroke color's tints and shades stay in that color's model; these are VectorCraft's own rules. @@ -1166,6 +1243,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Brushes panel's Delete Brush button and panel menu item ask "Delete the selected brushes?" with Yes and No when none of the brushes is in use. Alt-click (Option-click on macOS) on the button deletes them without asking, as the [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for panel deletion outside the Layers panel (archived copy). When a stroke uses one of the brushes, the button, Alt-click and the menu item all ask with Remove Strokes and Cancel; Remove Strokes deletes the brushes and the strokes that used them become plain strokes. Tests: `panels::brushes::tests`, `dialogs::brush_delete::tests`. Document Info has a Brushes category after Graphic Styles. It lists the Brushes panel's brushes, or with Selection Only it lists the brushes that the selected objects' strokes use. The Document Info report and Package's report list them too (`document.info {category: "brushes"}`, `brushNames`). Document Info has a Repeat Objects category after Embedded Images. It lists each radial, grid and mirror Repeat object by name with its kind and the number of instances it makes. For a grid it gives the rows and columns instead, followed by the number of instances it draws when that is fewer. With Selection Only it lists the Repeat objects in the selection. The Document Info report and Package's report list them too (`document.info {category: "repeats"}`, `repeatObjects`). + Document Info's Pattern Objects category lists the objects whose fill or stroke is a pattern, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes, each by name with the patterns it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "patterns"}`, `patternObjects`). Listing an object with a pattern fill and a pattern stroke once, listing objects inside groups and type objects whose characters use a pattern, and leaving out hidden fills and strokes, strokes with no width, guides, a compound path member's own fills and strokes, an image's fills and strokes without a shape, characters the type does not draw and patterns no art uses, and counting the listed objects in Objects, are VectorCraft's own rules. Test: `cmd::docinfo::tests::info_lists_the_pattern_objects` (vectorcraft-engine). + Document Info's Save… writes the category the panel shows, with Selection Only applied, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes ("Exports the selected category's visible information to a text file"). With All Categories shown it writes every category, which is VectorCraft's own rule (`docInfo.save {category}`). Test: `panels::doc_info::tests::save_writes_the_category_the_panel_shows`. Document Info's Save… writes the category the panel shows, with Selection Only applied, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes. With All Categories shown it writes every category, which is VectorCraft's own rule (`docInfo.save {category}`). Test: `panels::doc_info::tests::save_writes_the_category_the_panel_shows`. The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`). A rename in Brush Options and Delete Brush change every stroke that paints with the brush: in the layers and opacity masks, symbols, graph designs, pattern tiles, graphic styles and the art of other brushes. A rename gives those strokes the new name, and Delete Brush makes them plain strokes, in one undo step. @@ -1262,6 +1341,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a `.ai` or EPS opened from its editing data, type has its text object's opacity, blend mode and knockout shape flag, whether it is made from the file's text document or taken from the page, and type made from the text document takes its text object's lock. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, with their words in any case, that type is theirs as the page draws it and is left out; the page's other type of no text object goes only to text objects whose type the text document can't make, or else is left out with a warning. `.ai` editing-data import restores the document's listed graphic styles in panel order and links objects that use a listed style; unsupported appearances keep their drawn look (`crates/eps/src/import/ai/tests.rs`). A `.ai` or EPS graphic style library, whose editing data holds the styles and their list but no layers, opens with those styles in list order. Other Library… loads another document's graphic styles ([Apply graphic styles](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/apply-graphic-styles.html)) and lists such a library's styles in a library panel. A `.ai` or EPS read as its PDF part or page because its layers can't be used also gets the styles of its list in place of the default ones (`crates/eps/src/import/tests_native.rs`, `cmd::fileio::tests_aiimport::a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles`). + A `.ai` or EPS symbol library, whose editing data holds symbol definitions and their list but no layers, opens with those symbols in list order, each with its registration point. Other Library… in the Symbols panel's Symbol Libraries Menu imports the symbols of the file you pick ([Create or import symbol libraries](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)) and lists such a library's symbols in a library panel. A `.ai` or EPS read as its PDF part or page because its layers can't be used also gets the symbols defined before its layers (`crates/eps/src/import/tests_native.rs`, `crates/eps/src/import/ai/tests.rs`, `cmd::fileio::tests_aiimport::a_symbol_library_in_a_ai_file_without_layers_loads_its_symbols`). A `.ai` or EPS opened from its editing data whose text document makes the type of every text object that shows takes all its type from the text document: each text object stays in its own group, frame and layer, the page's type isn't matched with them, a file of more than 5,000 text objects still opens with its layers, and type the page draws differently (in another font) keeps the layers, with a warning. A line break inside a paragraph stays a line break in it (`crates/eps/src/import/tests_native.rs`, `crates/eps/src/import/ate.rs`). In SVG export without Fewer `<tspan>` Elements, each character of live type that justification or Mojikumi moves (the letter gaps of a Justify All line without word spaces, the gaps between the characters of a justified Japanese line, closed-up punctuation, the space between Japanese and Latin letters) starts its own positioned `<tspan>`, and type scaled horizontally on a justified line is stretched to its own width (`crates/svg/tests/it/text_layout.rs`). To find those characters, SVG export lays a type object out a second time only when the second layout can differ from the first: point type with Line-end Punctuation Half Width and no Japanese punctuation or letters, the setting new type gets, is laid out once. Exporting 5,000 such objects takes a median of 109.4 ms instead of 171.5 ms on Windows 11, with the same SVG (`crates/svg/src/export/text/tests.rs`). @@ -1276,8 +1356,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai` or EPS opened from its editing data keeps each artboard's center mark, cross hairs, video safe areas and video ruler pixel aspect ratio, and the file's Document Raster Effects Settings; the author comes from the header's `%%For` (the editing data's, else an EPS's own) when the file's File Info gives none, the header's custom colors that no swatch has come in as spot swatches, a named color the file defines takes the place of a default swatch of that name when the file has no palette, and an older file's `%%TemplateBox` places its artboard. A value of another type or out of range keeps its default (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). 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`). + In a `.ai` opened from its editing data, an object in a pattern's art or a brush's art with an art style VectorCraft reads opens as itself with its effects live, as an object on a layer does, and an object in a pattern's art keeps the id of a graphic style in the file's list (`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 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`). @@ -1286,15 +1367,18 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `document.open`, `file.place` and `document.pdfInfo` reject a `cropTo`, `crop`, `password`, `colorMode` or `textAs` that is not a string, such as `"textAs": true`, and `document.pdfInfo` rejects a `thumbnailSize` that is not a number; the error names the param, and nothing opens or is placed. A param left out or given as null keeps its default (`crates/engine/src/cmd/fileio/tests_pdfimport.rs`). When the PDF content of a `.ai` has a page for each artboard, the layers are compared with every page on its artboard (until the pages compared take 8,388,608 pixels at the comparison's scale, about 67 letter-size pages, or number 128): art that only the layers or only a later page draws is handled as art that differs on the first page is, and a text object that a later page doesn't draw is left out unless its layer doesn't print (`crates/eps/src/import/tests_native.rs`). When the editing data of a `.ai` or EPS is for a page of another size than the file's, the warning that its layers weren't read gives both sizes in points, and the scale of the editing data's page to the file's page (1:10 for a tenth of its size) when their sides are in proportion (#2034; `crates/eps/src/import/tests_native.rs`). + A `.ai` or EPS opened from its editing data keeps its layers when it embeds an image of more than 2^25 pixels (part of #1479). The image comes in at full size up to a target size (2^26 to 2^28 pixels on the desktop, by installed memory; 2^27 on the web), a 1-bit gray image as a 1-bit PNG. A larger image is averaged down by the smallest whole step that fits, a 1-bit one into shades of gray, with a note giving both sizes. An image of more than 2^31 pixels or 512 MiB of samples is an object the reader can't read, so its box comes in as the page's art; without page art, it is left out with a note (`crates/eps/src/import/tests_native.rs`). A `.ai`, or a PDF saved with Illustrator's editing data, whose editing data can't be decompressed opens from its PDF part with a warning that its layers weren't read and why, and an export or Save As to its own path asks first (`acknowledgeLoss`), as for a file whose layers the reader can't read (`crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, 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 the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type keeps its drawn art, with its type hidden (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, Convert to Shape on a whole path or type object opens as a live Rectangle, Rounded Rectangle or Ellipse effect with its Absolute or Relative size, its corner radius, its place among the object's effects and whether it is hidden. A relative size in the file is added to each side. A relative Ellipse, or a setting VectorCraft doesn't read, keeps the drawn art (`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, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`). 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. PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked. 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, at its path, or in the folder the editing data names relative to the document's, such as `src/`, is read as the document opens, and its link takes that file's size, modification time and hash). 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). 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). + A placed PDF or `.ai` file in a `.ai`'s editing data comes in as a placed document instead of a linked image: it shows the page and Crop to box its dictionary names, and the preview the file keeps until its file is found, then the page as vectors. A placed file in opacity-mask art keeps showing its preview (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/placed_document.rs`). In a `.ai` or EPS opened from its editing data, and in a legacy `.ai`, each anchor is smooth or a corner as the case of the segment operator ending on it says (`l`, `c`, `v`, `y` smooth; `L`, `C`, `V`, `Y` corner), and a closed path's first anchor takes the case of the segment back to it; points and handles stay where they are. Paths, compound paths, clipping paths, guide paths, area type frames and type-on-a-path paths all keep it (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/legacy_ai/tests.rs`). In `.ai` and EPS editing-data import, a clipping group, layer or opacity mask's art with several clipping paths (`W`) clips to their intersection: each clipping path after the first clips a clipping group nested inside the one before it, and each keeps its own stroke and transparency. A compound path of clipping paths (`*u` … `*U`) is still one clipping path, and clipping paths that would nest the art past the document's 32 levels stop the editing data from being read (`crates/eps/src/import/ai/tests.rs`). `.ai` and EPS editing-data import keeps template layers, and opens canvas and artboard ruler guides as ruler guides on their layers; guide objects stay editable guide paths (#779; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). @@ -1303,6 +1387,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai` saved without PDF compatibility (its PDF part only a placeholder page) is placed and pasted from its editing data, as it opens: File › Place places the art of the layers that show on the first artboard (`file.place`'s `page` places another one), and Paste of it as PDF data (`clipboard.importPdf`) takes every object. `document.open` with `pages` naming every page of such a file opens it the same way (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai`, or a PDF saved with Illustrator's editing data, whose PDF part can't be read (a stream the import refuses) opens from its editing data, with the PDF part's error as its first warning; File › Place places its first artboard the same way, and Paste of it as PDF data with `editingData: true` takes every object (part of #4366; `crates/engine/src/cmd/fileio/tests_aiimport.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part; 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`). - The Open and Place dialogs list PostScript `.ps` files and `.epsf` files. A `.ps` file opens and places through the EPS reader (its first page), also when its header is only `%!`, as the `ps` format, which exports don't write (Print to PostScript does); an `.epsf` file opens and places as EPS. Copied in a file manager, these files paste as EPS, and `clipboard.importEps` also reads PostScript whose header is only `%!` (`crates/engine/src/cmd/fileio/tests_epsimport.rs`). `document.pdfInfo` reads an EPS, a `.ps` file or a `.ai` in PostScript form as one page the size of its bounding box (a US Letter page without one), and its `thumbnail: 1` is a PNG of what that page prints (`crates/engine/src/cmd/fileio/tests_pdfimport.rs`). - EPS export with Use Artboards writes a central EPS file of the whole document, bounded by its visible art, beside the file of each artboard when it writes two or more artboards; a document with no visible art gets only the artboard files (`crates/engine/src/cmd/fileio/tests_eps.rs`). @@ -1312,13 +1397,14 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - PCX files (`.pcx`) open and place, from the ZSoft PCX Technical Reference Manual: 1 bit in the header palette's first two colors (black and white when the version has no palette or both colors are the same), 1 bit in two to four planes and 4 bits with up to 16 colors from the header's palette (the EGA colors for versions 0 and 3), 2 bits in the CGA colors that the header's CGA palette settings pick (background, color burst, palette and intensity), 8 bits with the 256-color palette after the image data (gray levels without one) and 24-bit color in three planes, run-length encoded. The header's HDpi and VDpi size the image. A file of another depth or plane count, one cut short, and one whose pixels as RGBA pass the pixel allowance are refused with a message before its pixels are decoded. Open and Place recognize a PCX file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a PCX file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_pcxread.rs`). - Pixar picture files (`.pxr`) open, place and paste, from "The Format of Stored Pictures" in the Pixar Image Computer manuals: any set of the R, G, B and A channels in 8 or 12 bits, dumped or run-length encoded with all four packet kinds and block ends, in tiles of any size (pixels of border tiles past the picture are dropped). R alone is gray, a missing color channel is 0 and a missing A opaque; 12-bit samples are clamped to 0.0 to 1.0; color matted to black becomes straight color; a null tile is transparent when the picture has A and black otherwise. The offsets aren't read, and the image takes the default resolution. Version 2 headers read like version 0. A file cut short, one with a tile whose writing wasn't finished, one with header values or packets the format doesn't define, and one whose pixels as RGBA pass the pixel allowance are refused with a message. Every reader recognizes a Pixar file by its magic number. Tested on synthetic files only (`crates/engine/src/cmd/fileio/tests_pxrread.rs`). - JPEG 2000 JP2 and raw codestream images open and place from `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c` and `.jpc`. Each axis uses the valid display resolution (`resd`), else the valid capture resolution (`resc`), whichever box comes first ([ITU-T T.800](https://www.itu.int/rec/T-REC-T.800/en), I.5.3.7.2); an axis with none uses 72 ppi. Multiple codestreams, JPX composition or layers, and unsupported color spaces are refused with a message. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_jp2read.rs`). + - HEIC and HEIF images open and place from `.heic` and `.heif`, as the [supported file formats](https://helpx.adobe.com/illustrator/kb/supported-file-formats-illustrator.html) list High Efficiency Image under both Open and Place (and [Fix HEIF file import errors on Windows](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/fix-heif-file-import-errors-on-windows.html) covers both ways of bringing HEIF files in): HEVC-coded single pictures and grids of tiles, 8 to 10 bits, with an alpha image, decoded by heic-rs in the engine's default `heif` feature. The container's rotation, mirror and crop are applied. Without an `nclx` box, the HEVC stream's video signal type gives the samples' matrix and range. An RGB ICC profile, or Display P3 with the sRGB curve named by an `nclx` box or, without a color box, by the stream, is converted to sRGB; the EXIF resolution tags size the image, or 72 ppi. Image sequences, other codings and grids of cropped tiles are refused with a message, and so is every HEIF file in a build without the feature. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_heifread.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs. - EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. - EPS and PostScript `.ai` import undoes the TIFF predictor (`/Predictor 2`) of `FlateDecode` and `LZWDecode` data at 1, 2, 4 and 16 bits a component as well as 8: each component adds the same component of the pixel to its left within its bit width, 16-bit components add as big-endian numbers, and each row starts again (ISO 32000-1 §7.4.4.4; `crates/eps/src/import/tests_images.rs`, `crates/engine/src/cmd/fileio/tests_epsimport.rs`). At those depths the image drew the stored differences instead of its samples. - - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); an open path of one straight segment exports as a LINE (R12 to R14 strokes with a weight stay polylines, which carry the width) and reads back as the same segment (`crates/cad/src/tests.rs`, `crates/cad/src/import/tests.rs`); CNC preset and VCarve QA remain pending. + - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); an open path of one straight segment exports as a LINE (R12 to R14 strokes with a weight stay polylines, which carry the width) and reads back as the same segment (`crates/cad/src/tests.rs`, `crates/cad/src/import/tests.rs`); DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (version 2000, 1 mm = 1 unit, 256 colors, Maximize Editability with Alter Paths off, Outline Text, PNG placed images), and the menu shows Custom once a field changes (`the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines`, `the_cnc_preset_sets_the_options_that_are_not_given`, `the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom`); VCarve QA remains pending. - DXF export writes a path of straight segments and circular arcs (a slot, a D shape, a rounded rectangle, a pie) as one polyline whose vertices carry a bulge (group 42) for each arc: an LWPOLYLINE in R14 and later, a POLYLINE with VERTEX entities in R12 and R13, closed when the path is closed, and it reads back as the same outline (#1299, `crates/cad/src/tests.rs`). Such a path was written as one SPLINE (a flattened polyline in R12); a path with any other curve still is. A shallow curve becomes an ARC or a bulge only when it lies on the circle within 5e-4 of its own extent as well as of the radius, and the drawing extents (`$EXTMIN`, `$EXTMAX`) reach the furthest points of every ARC and bulge. - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.5 (from DesignCraft's import, storytold/designcraft#291) adds master pages (on a locked layer behind the pages that show them), tables (area type per cell and a line per edge), each paragraph's alignment, indents, spacing and leading, horizontal text scale, outlined text, frame text sized by its text scale, drop shadows and outer glows as live effects (inner shadows as inner glows), outline effects as strokes, colour and gradient overlays baked into the paint, embedded Affinity documents as editable content (their artboards don't become artboards), pixel layers over 64 MP at a reduced resolution and HEIC photos from their stored pixels; automatic leading (size plus the font's line gap) fitted on thumbnails; underlines and strikethrough; strokes whose style is None (every shape stores one) left out, which also brings 6 public files closer to their thumbnails; line breaks inside a paragraph; table cell text centred as Affinity centres it, also where it is taller than its cell; CMYK pixel layers converted through the document's embedded CMYK profile (relative colorimetric with black-point compensation, as each layer's colour settings name), or kept as CMYK images when there is none; outline effects on stroked shapes around their stroke; artistic text whose paragraphs align differently aligned within its longest line, a frame's first baseline at cap height, and frames whose last lines' descenders hang out made tall enough to show them (all measured on a local document's own PDF export: baselines match to 0.1 pt); a local 56-artboard document's mean thumbnail difference went from 16.2 to 11.4, and its pages' mean difference from the owner's PDF export from 12.9 to 9.3. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it. @@ -1332,6 +1418,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - PDF export and PDF-compatible `.ai` and `.ait` saves write File Info's copyright notice, URL and status to the XMP packet (`dc:rights`, `xmpRights:WebStatement`, `xmpRights:Marked`), so a PDF or `.ai` opened from its pages keeps them (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). - Opening a PDF or PDF-compatible `.ai` whose XMP packet nests elements more than 256 deep no longer overflows the stack: File Info leaves the packet out and takes the document information dictionary's values (`crates/pdf/src/tests/file_info.rs`). - `file.print`, `print.preview` and `print.setup` take a print preset by name (`preset`: `[Default]` or a saved preset, in any case): its settings are used instead of the document's, with `settings` over them, `print.setup` saves them with the document, and an unknown name is refused with the list of presets. + - Print's Copies per Sheet (`copiesPerSheet`, `gutter`; off by default) prints as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, as one grid placed like a single copy; the preview shows them, and an artboard that does not fit prints one copy per sheet with a warning (#1283). - Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457). - File › Place reads RTF files as area type with their formatting: each run's font, size, bold and italic, underline, strikethrough, caps, superscript or subscript, baseline shift and color, and each paragraph's alignment, indents, space before and after and direction. Table cells are separated by tabs; hidden text, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests/textimport.rs`). - File › Place reads Word `.docx` documents as area type with the same formatting, taken from the document defaults, the paragraph and character styles (their toggle properties reversing each other) and direct formatting, the theme's fonts included. Table rows are paragraphs with their cells separated by tabs; hidden and deleted text, field instructions, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests/textimport.rs`). @@ -1349,6 +1436,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected. - The Links panel's Relink… opens a Relink dialog whose Replace menu picks which links take the new file: Selected Instances (the selected images, as before), All Instances (every image linked to the same file), Missing Links from This Folder (then every other missing link whose file name is in the new file's folder) and Each Link Separately (a file for each selected link in turn). Each relinks as one undo step (`links.relink {scope}`, `ui.relinkDialog`). Relink to Folder… stays as it is. - Linked files inside symbols, graph designs, patterns and opacity masks are saved with their paths relative to the document, as linked files in the layers are, so they are found after the document and its files move to another folder together; on open each of those links takes the path where its file was found. Update Links, and opening with Update Links: Automatically, read a modified file again for that art too, and saving a document refreshes its placed copies inside symbols and patterns of the other open documents. Evidence: `links_outside_the_layers_find_their_files_after_the_folder_moves`, `update_links_reads_files_again_inside_symbols_patterns_and_masks`, `saving_a_document_refreshes_its_placed_copies_inside_symbols_and_patterns`. + - A linked file that is missing when a moved document is saved keeps the folder its relative path names, instead of a relative path worked out from the out-of-date absolute path, also for a `.ai` file another app wrote (such as `src/`), so the file is found once it is back in that folder. Evidence: `a_missing_link_keeps_its_relative_path_when_the_moved_document_is_saved`, `links_of_an_imported_file_are_found_by_their_relative_folder`, `a_missing_link_of_a_moved_document_keeps_its_relative_place`. - On Windows, a document opened from a mapped network drive finds its links stored with the share's own path (`\\server\share\…`), as the same document opened from the share does; links on other computers stay missing. - On Windows, a link on a drive mapped to a network share other than the document's (`Y:\…`) is missing, with the reason that the path is on another computer, as a link stored with that share's own path is; links on this computer's drives and on the document's share are followed. - In the Links panel, Shift-click selects a range of links and Ctrl/Cmd-click adds or removes one. @@ -1374,11 +1462,15 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Hard-edge rendering, including the internal hard-edge frame of Art Optimized supersampled raster exports, draws a knockout element with one opaque solid fill once instead of erasing and adding it through separate layers. Analytical antialiasing keeps the existing compositor and rounding. Synthetic path/compound-hole tests compare exact pixels against the general knockout path across precision, supersampling, translucent parent groups and backdrop blending; translucent fills retain their knockout behavior. An image whose pixels can't be decoded, such as a PNG cut short or an image over the 2 GB decoding allowance as RGBA that can't be drawn at reduced size, is tried once: the renderer notes it and does not try to decode it again on later frames. An image refused only because the images being drawn leave no room for it is tried again on the next render. On the canvas such an image draws as a light gray box crossed from corner to corner, and the status bar says once for each image that it couldn't be decoded; exports leave it out and report it as before. A PNG, JPEG, GIF or WebP image that takes more than 2 GiB as RGBA (512 MiB in the web app) draws in the canvas and in raster exports at the largest size within that allowance, instead of as that crossed box on the canvas and not at all in exports. Each pixel of the result is the mean of the image's pixels it covers, and the document keeps the whole file. A PNG that isn't interlaced is read a row at a time, and other images are read whole when their decoded samples fit in the allowance. A 16-bit PNG that isn't interlaced, whose decoded samples take more than the allowance and whose RGBA fits in it, draws at its own size, read a row at a time. An image of more pixels than the allowance has bytes (on the desktop, 46,341 × 46,341 is more) still draws as the crossed box, and so does one whose decoded samples take more than the allowance, other than a PNG that isn't interlaced. Open, Place and the Links panel say "an image of W × H pixels is shown at reduced size", and say "too large to draw" of an image that is not drawn at reduced size. + The renderers keep the decoded images they share within a quarter of the installed memory on Windows, macOS, Linux and FreeBSD (on Linux, of the process's cgroup memory limit where that is lower, and in a 32-bit build at most a quarter of the address space), within 1 GiB where the installed memory can't be read, and within 256 MiB in the web build; while the decoded images add up to less than that budget, they are not decoded again when the view changes. With `--deep`, each memory row of `vectorcraft-cli perf` also gives the peak so far: the peak working set on Windows and the peak resident memory (`VmHWM`) on Linux. A new last row gives the memory after the SVG export and the save → open → save at the end of the run. The renderer composes the two ink planes of a CMYK document that shows transparency into screen colors on up to as many threads as it rasterizes with, instead of on one. At 2880 × 1800 on 8 threads, a page of 300 shapes at 20% to 90% opacity takes 30 ms a frame instead of 55 ms, and 50,000 paths with one at 99% opacity take 258 ms instead of 361 ms (`vectorcraft-cli bench`), with the same pixels. With Overprint Preview or Separations Preview on, the renderer keeps the art it prepared for the preview from one frame to the next. It prepares again the objects that changed, and all of the art when Discard White Overprint changes or, with Separations Preview, when the plates shown, the proof setup, the swatches, the Spot Colors option or the color settings change or a profile is loaded. It draws the other objects from the art, geometry and stroke outlines it kept for them. + Pathfinder › Merge and the `pathfinder.merge` group effect find the faces of every fill in a few contour passes over the arrangement, as Trim and Crop do, instead of one pass for each distinct fill. The output paths and their order are the same. With 8,000 squares that do not touch, each with its own fill, Merge takes 72 to 107 ms instead of 17 to 32 s (release build). The Pathfinder panel, Offset Path, Clean Up, Divide Objects Below, Make and Release Blend, Envelope Distort's Make and Release, Repeat (Radial, Grid, Mirror and Release), Vector Halftone, New and Redefine Symbol, Delete Symbol, erasing with the Symbol Sprayer, Rasterize, Convert to Artboards, Flatten Transparency, Clear Guides, Shape Builder, Live Paint, Release Image Trace, Make and Release Opacity Mask, Delete with direct-selected anchor points, Remove Anchor Points, Cut Path, and the Shaper, Knife, Eraser, Path Eraser, Line Cut, Rectangle Cut, Mirror & Cut and Blob Brush tools add and remove objects in one pass over each layer or group they change, and Outline Stroke and Expand write each new object in the place of the object it replaces (#1429). Offset Path looks up the segments near each candidate hole or island in a tree of the source path's segment bounds when it decides whether to drop that contour, instead of testing the bounds of every segment. The output paths are the same. On compound and open paths of 25,000 to 40,000 anchors with 6,400 to 11,976 candidate contours, that check takes 11 to 13 ms instead of 140 to 649 ms (medians of five interleaved runs). The limit on overlapping segment pairs is the same, so a path that stops with "Offset Path: the path is too complex to offset" still stops there. + Finding the distinct regions of an arrangement lists the shapes that cover each region instead of keeping a flag for every shape, so that step's time follows how many shapes cover each region rather than how many shapes there are. Divide, Hard Mix, Soft Mix, Shape Builder, Flatten Transparency, trapping, Intertwine and the Pathfinder effects are among the commands that use that step. The regions, their order and the output are the same. Divide on 3,000 overlapping rectangles takes 0.84 to 1.13 billion CPU cycles on its thread instead of 1.25 to 1.46 billion (dev build, six runs of each, alternating, while other builds were running). + The contour passes of the two-path boolean operations, Offset Path, compound shapes and Intertwine test the list of shapes that cover each face instead of a flag for every shape, so each test's time follows how many shapes cover the face rather than how many shapes there are. The contours, their order and the output are the same. The many-shape boolean that compound shapes use takes 0.75 to 0.83 billion CPU cycles on its thread instead of 1.28 to 1.33 billion to unite 10,000 squares that do not touch, and 2.27 to 2.30 billion instead of 2.43 to 2.47 billion for 1,500 members of two squares each over 3,000 overlapping squares (dev build, six runs of each, alternating, while other builds were running). With more than 10,000 type on a path objects selected, the Selection and Direct Selection tools neither draw nor hit-test the objects' brackets. The renderer outlines the stroke of a path object with more than 1,024 path elements, without dashes or a width profile, only in and around the view, with a margin of the view's size on each side, and outlines it again when the view moves past that margin. The pixels are the same. At 1600 × 1200, the frame after one anchor of an open path of 200,000 anchors moves takes 28 to 30 ms instead of 103 ms, and with a closed path of 50,000 anchors 10 ms instead of 30 ms (`cargo run --release -p vectorcraft-engine --example anchor_bench`). The renderer outlines a dashed stroke of such a path the same way, without a width profile, and draws its dashes and dots in the same places as when the whole path is dashed, so the pixels are the same. A pattern too fine to draw is drawn solid, as before, and its stroke is outlined near the view too. At 1600 × 1200, the frame after one anchor moves takes 14 ms instead of 102 ms with a filled closed path of 50,000 anchors and a stroke of 6 pt dashes and 4 pt gaps (`cargo run --release -p vectorcraft-engine --example anchor_bench`, which now includes that document). @@ -1402,6 +1494,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur An Alt-drag of objects in perspective with the Perspective Selection tool, and an Alt-drag of a plane widget with that tool or the Perspective Grid tool, copy the objects on the first move, and the moves after it move those copies (#1429). With the Perspective Selection tool, Ctrl+Alt-drag (Command+Option-drag on macOS) also copies objects on their perspective plane; Alt/Option-drag continues to do so. With the Perspective Selection tool, holding Shift while moving or copying constrains each object's displacement to the nearest axis or 45° diagonal of its own plane; releasing Shift resumes the raw pointer position, and 5 still moves perpendicular to the plane. Snap to Grid follows the constrained line on the first object's plane and depth. + With Snap to Grid on, a move of objects on one perspective plane at different depths moves them all by the same offset on the plane, so they keep their spacing, and the edge of their joint bounds nearer a gridline lands on it. Regression: `snap_moves_objects_at_different_depths_by_one_plane_offset` in `crates/engine/src/tests/persp_select.rs`. When the desktop app opens a document on a thread of its own (#1245), that thread also reads the document's linked files and lays out its type, and the UI thread only adds the finished document: in release builds, the step left on the UI thread takes 0.02 ms instead of 1.04 s for a document with 12 linked 3000 × 2000 TIFFs, and it takes 0.1 ms instead of 0.85 s for one with 300 Auto Size area type objects, every second one in three columns. Laying out type keeps each font face's shaping plans, at most 32 per face, so type objects of the same font, script, direction, language and features share one plan: the layouts of a document of 5,000 short point type objects take 59 ms instead of 73 ms in a release build, and the glyphs, bounds and baselines do not change. Laying out type looks up the font face of each style run once and uses it for the run's glyphs and for the line metrics of a paragraph that starts in that run: the layouts of a document of 5,000 short point type objects take a median of 215 million CPU cycles instead of 236 million in a release build (11 alternating runs of each build on Windows 11, measured while other builds were running), and the glyphs, bounds and baselines do not change. @@ -1421,6 +1514,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`). Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780). One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779). A drag whose last preview failed is no longer recorded as a committed command in the journal that actions and scripts read (#1775). Tests: text import fuzzing no longer searches installed fonts (#1785). Tests: render tests that add fonts no longer race the frame cache tests (#1795). - **Raster-effect resolution in PDF:** when the 64 million pixel cap on an effect image renders an object's raster effects below the document's raster effects resolution, PDF export warns once per object with the requested and the lowest achieved ppi (`crates/engine/src/tests/pdfraster.rs`). - **Rasterize effect:** Effect › Rasterize… (`rasterize`) draws an object as an image of square pixels at its own resolution or the document's raster effects resolution, with Object › Rasterize's color model, background, anti-aliasing, clipping mask and room around the object, while its paths stay editable. Expand Appearance and PDF and SVG export of an object whose only raster effect is Rasterize give the image Object › Rasterize gives (`crates/engine/src/tests/rastersettings.rs`, `crates/render/src/tests/rasterize.rs`). Behavior reference: [How to save artwork in Illustrator](https://helpx.adobe.com/illustrator/using/saving-artwork.html), [Work with effects](https://helpx.adobe.com/illustrator/using/effects.html). +- **SVG filters:** Effect › SVG Filters lists the document's own SVG filters below Apply SVG Filter… and Import SVG Filter…, and choosing one applies it to the selection as a live effect in one undo step (`svgFilter.apply`). Apply SVG Filter applies the selected filter, New and Edit open its code, and Delete and Import… remove filters or add those of an SVG file (`svgFilter.define`, `svgFilter.delete`, `svgFilter.import`). VectorCraft ships no default filters. The canvas, raster export and PDF draw the filter at the Document Raster Effects resolution; SVG export writes it as a `<filter>` element when it is the object's last effect and otherwise draws the object as an image; SVG import keeps filters the other live effects can't express as this effect instead of leaving them out; the native format keeps the filters (`crates/engine/src/tests/svgfilter.rs`, `crates/effects/src/svg_filter/tests.rs`). Behavior reference: [Apply SVG filter effects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-svg-filter-effects.html), [CS5 help, Apply SVG effects](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=402), [SVG 1.1 Filter Effects](https://www.w3.org/TR/SVG11/filters.html). - **Pattern tiles:** the renderer keeps a tile of each pattern for each scale it rasterizes the pattern at, until the tiles fill the tile cache's budget, and then drops the least recently used tiles first. Objects that show one pattern at different scales, such as a pattern scaled to 25% with Transform Pattern Tiles next to the same pattern at 100%, draw from tiles kept between frames. @@ -1441,6 +1535,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Envelopes of type map from the glyphs:** an envelope maps point type, area type, type in groups and layers and type used as a clipping path from the bounds of the glyph outlines, underlines and strikethroughs instead of the type's laid-out box (ascent to descent, or the area's frame), with inline graphics counted by their boxes; rotated type and type in perspective map from their outlines as drawn. Make with Mesh, Make with Top Object and Reset with Mesh without Maintain Shape use those bounds. Type that draws nothing keeps its laid-out box, and so does type inside any other object, such as a blend, a repeat or a symbol instance. This is VectorCraft's own rule; envelopes of type made in VectorCraft and in saved files redraw by it. Tests: `crates/engine/src/tests/envelope.rs`, and `tests/envelope_ink.rs` in `vectorcraft-eps`, `vectorcraft-render` and `vectorcraft-effects`. Scores and hours unchanged. +- **Top-object envelopes share each side by control polygon:** each piece of a top object's four sides takes a share of the content's width or height in proportion to the length of its control polygon, spread along the piece by its curve parameter (evenly along a segment without handles), where the sides used to share it out by length. Each corner is the nearest anchor within 47.5% of the bounding box from its box corner that has a handle 0.01 pt long or shorter, or whose tangent unit vectors differ by 0.01 or more in x or y and turn out of the outline, or else the outline's nearest point to that box corner. Envelopes opened from `.ai` files keep the corners the file saves, through moves, rotations and reflections. These are VectorCraft's own rules; existing top-object envelopes redraw by them, and gradient meshes made from a path keep sides measured by length. Tests: `a_top_object_*`, `a_reflected_top_object_keeps_the_corners_a_file_gives`, `a_split_curve_moves_the_top_object_share_to_its_new_anchor`, `a_rounded_rectangle_top_object_stays_close_to_the_identity` and `a_rectangle_top_object_is_the_identity` in `live::tests` (`vectorcraft-doc`), `envelope_top_object_shares_a_side_by_control_polygon_length` in `vectorcraft-engine`, `a_top_object_envelope_opens_live_with_its_outline` in `vectorcraft-eps`. Scores and hours unchanged. + - **Texture redraws (#1257):** Craquelure, Mosaic Tiles and Stained Glass compare squared distances when finding their nearest cells; rounding/tie boundaries use the original distance ordering. Brute-force candidate tests and three 1800×1500 viewport comparisons preserve the old pixels. A CMYK rectangle move at 190.07%/DPI 1.5 is reproducible with `cargo test -p vectorcraft-render texture_drag_perf -- --ignored --nocapture`; idle-machine Windows interaction is still unverified. - **Smart Blur modes:** Effect › Blur › Smart Blur… has a Mode menu with Normal, Edge Only and Overlay Edge (`blurMode`: `normal`, `edgeOnly` or `overlayEdge`; an effect saved without it draws as Normal). Edge Only draws the edges in white on black, and Overlay Edge draws them in white over the Normal blur. A pixel is on an edge when one of the pixels the search reads within Radius of it differs from it by more than Threshold in a channel and its channels, alpha included, add up to at least that pixel's. The search reads neighbors as the Normal blur does, kept within Radius, and always the four nearest pixels, so a color step gives a line about as wide as Radius, and at least one pixel wide, on the side with the larger sum (lines on both sides when the sums are equal). The white and the black keep each pixel's coverage. Behavior reference: [Summary of effects](https://helpx.adobe.com/illustrator/using/summary-effects.html). @@ -1455,7 +1551,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, Targa, PSD, PSB, PCX, Pixar or JPEG 2000 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, Targa, PSD, PSB, PCX, Pixar, JPEG 2000, HEIC/HEIF 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. - **Bounded effect length inputs (#4638):** Effect dialog fields with defined length ranges (such as Crystallize Cell Size 3..300 pt) clamp immediately on commit and reject invalid inputs visibly with an accent highlight without reverting to sub-minimum values. - **Color Halftone in raster pixels (#3260):** Max Radius is 4–127 px of the Document Raster Effects resolution, as public documentation gives it, shown in px whatever the document units; documents from before open with their radii in points converted (document version 3), and a file saved for an older app version holds them in points again. @@ -1476,6 +1572,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Envelope names given as another type:** `object.envelope.makeWithWarp`, `resetWithWarp` and `options` refuse a `style` or `orientation` other than text or null, such as `5`, and an `orientation` other than `horizontal` or `vertical`; `object.envelope.options` refuses such a `preserveShape`. `object.envelope.options` checks the warp keys with no envelope or only mesh envelopes selected, too. Each leaves the document, the options new envelopes get and the 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.<id>`, 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 instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.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. @@ -1483,9 +1580,18 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Filter plug-ins set effects and stroke styles (G17):** a filter is given each object's effect records and its strokes' `join`, `cap` and `miterLimit`, and may return them; the records are admitted as `effect.apply` admits them (a known effect or installed live-effect plug-in, parameters it takes, one 3D effect that fits, no texture read from a file), and one refused refuses the output. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. +- **Safe Mode for plug-ins (G17):** `vectorcraft --safe-mode` and `vectorcraft-cli --safe-mode <subcommand>` start without plug-ins. When a launch stopped while a plug-in was loading or drawing its first live effect, the next launch names that plug-in, without saying it caused the stop, and offers Disable and Continue, Start Normally or Start in Safe Mode. Help › Safe Mode lists the disabled plug-ins with Enable on Re-launch, which takes effect at the next launch, and the app bar and the window title show Safe Mode. The help page (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, pages 65 to 69) describes the same choice between diagnosing and starting normally, a Safe Mode with the problem item off and the rest of the app working, and turning the item back on at the next launch. In Safe Mode no plug-in loads or runs, and a live-effect plug-in draws its object's geometry unchanged while the object keeps the effect, which the Appearance panel marks "(Safe Mode)" with the plug-in's id in its tooltip and `effect.list` marks `skipped`; Expand Appearance refuses such an object. The web build keeps no plug-ins between page loads and has no marker. Each desktop launch with preferences keeps a marker in the `Launch` folder next to them; `vectorcraft-cli` and a desktop launch without preferences keep none, so a stop there leads to no choice at the next launch. Disabled plug-ins are listed in `Plug-ins/disabled.json`. `plugin.safeMode` reports Safe Mode and the disabled plug-ins, and `plugin.setEnabled` changes the list. Regressions: `crates/plugins/src/safe_mode.rs`, `crates/plugins/tests/safe_mode.rs`, `crates/engine/tests/plugin_safe_mode.rs`, `crates/ui-egui/tests/safe_mode.rs`. Scores and hours unchanged. - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.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. +- **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; for a plug-in that lists permissions, `plugin.install` needs `grant` and folders install it only when it is granted. 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. +- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (a CMYK image's through the working CMYK profile; never a linked file's), and symbol instances with their symbol and transform. The objects come in paint order from the bottom, and an image that reaches the decoder counts toward the 16 MiB of pixels even when decoding fails. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size, a path or compound path that doesn't fit fails it too, and so do more than 100,000 of these objects. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. Regressions: `crates/plugins/src/objects/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 grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module that lists permissions and has other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A module with a missing or empty permissions list needs no grant; without the list, it gets its kind's implied permissions. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **Plug-in approval needs a person (G17):** a plug-in's permissions and heavy budget are approved in a dialog that only a person can confirm: the control channel, MCP and injected input are refused, and `plugin.install` takes `grant` or `allowHeavy` only with that approval. `vectorcraft-cli mcp` and `run` refuse them unless started with `--allow-plugin-grants`. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/automation.rs`, `apps/vectorcraft-cli/tests/it/cli.rs`. Scores and hours unchanged. +- **A plug-in with no permissions ends its id's grant (G17):** installing a module that lists no permissions under a granted id forgets the grant, in the process and in `grants.json`, so the formerly approved bytes need a new approval. Attaching a `Plug-ins` folder whose module for that id lists no permissions forgets it too, and plug-in folders forget the process's grant. A refused install leaves the grant. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_safe_mode.rs`. Scores and hours unchanged. +- **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin.<id>.<command>` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.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 settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_clear_cache.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. +- **Plug-in settings and cache totals in memory (#6228):** the settings stay in memory while `settings.json` keeps its size and modified time, a set to the same value writes nothing, every other change is written to the file before the call returns, and `cacheBytes` comes from a total kept while the `cache` folder keeps its modified time. Regressions: `crates/engine/src/tests/plugins.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. @@ -1734,7 +1840,7 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov ## Dynamic symbols (2026-11-01) -Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own fill, stroke, opacity and blend for any object of the symbol's art, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol expands the symbol's own art; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them, and Replace Symbol drops them when it gives the instance a different symbol. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: Direct Selection of an object inside a dynamic instance on the canvas (with the Fill/Stroke controls then setting its change), and reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests. Score unchanged at 87% (estimated); hours 5–9 → 4–8. +Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own opacity and blend for any object of the symbol's art, and its own fill and stroke for an object that draws them, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol expands the symbol's own art; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them, and Replace Symbol drops them when it gives the instance a different symbol. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: on-canvas editing beyond a solid fill and stroke on a path, compound path or type object inside a dynamic instance; reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests. Score unchanged at 87% (estimated); hours 5–9 → 4–8. ## Document Tools add-on: Export All Open Documents (2026-11-01) @@ -1759,6 +1865,10 @@ Cmd/Ctrl+Alt+. (`stroke.weightUp`) and Cmd/Ctrl+Alt+, (`stroke.weightDown`) rais Edit › Keyboard Shortcuts lists every item of the Effect menu under its menu path (Effect › Distort & Transform › Transform…) and gives each one a shortcut of its own (stored as `effect:<id>`; they all run `effect.dialog {effect}`, or `effect.apply` for an effect without options, so a shortcut on the command alone couldn't tell them apart). The menu shows the shortcut, and pressing it opens that effect's dialog for the selection (#3296). Test: `shortcuts::tests::an_effect_menu_item_takes_a_shortcut_of_its_own`. +A live-effect plug-in installed while the app runs gets its Effect › Plug-ins row in Edit › Keyboard Shortcuts, in `shortcuts.list` and in a Keyboard Shortcuts dialog that is already open, and its key runs it. A removed plug-in's row goes away and its key runs nothing; the shortcut stays saved and applies again when a plug-in with the same id is installed, opening the effect's dialog if the new one has options and applying the effect if it has none. Tests: `shortcuts::tests::a_live_effect_plugin_installed_later_takes_a_shortcut_of_its_own`, `shortcut_editor::tests::an_open_dialog_follows_live_effect_plugins`. + +Each installed filter plug-in's Object › Plug-ins item has a row of its own (stored as `plugin:<id>`, since every filter runs `plugin.dialog {id}`), with no default shortcut, also when two filters share a name. The menu shows its shortcut, and pressing it does what a click on the item does: it opens the filter's dialog, or runs a filter without parameters at once as one undo step (a heavy filter starts in the background). A removed filter's row goes away and its key runs nothing; the shortcut stays saved and applies again when a plug-in with the same id is installed. With no document open the key does nothing, as the dimmed item does. The command palette has one result per filter, and Import Shortcut Set offers each filter as a target without matching a row to it by name. Plug-in names stay as the plug-in gives them; the menu path is translated. Tests: `shortcuts::tests::each_filter_plugin_takes_a_shortcut_of_its_own`, `shortcuts::tests::a_filter_plugin_keeps_its_shortcut_when_replaced_or_reloaded`, `shortcuts::tests::a_heavy_filter_plugin_runs_from_its_shortcut_as_from_its_menu_item`, `shortcuts::tests::a_filter_plugin_row_translates_its_path_and_not_its_name`, `palette::tests::each_filter_plugin_is_a_result_of_its_own`, `dialogs::tests_shortcut_import::an_imported_shortcut_reaches_a_filter_plugin_picked_as_its_target`. + ## Live Corners: arrow keys change the corner kind while dragging (2026-11-01) While a corner widget is pressed or dragged with the Selection or Direct Selection tool, the Up and Down arrows step the kind of the corners being edited (Round, Inverted Round, Chamfer, in turn, from the pressed corner's), shown live with the radius and kept, with it, as one undo step when the drag ends; a press with an arrow and no drag changes just the kind (#2898). Alt-clicking a widget still cycles it as before. Test: `tests::livecorners::arrows_while_dragging_a_corner_widget_change_the_corner_kind`. @@ -1767,6 +1877,10 @@ While a corner widget is pressed or dragged with the Selection or Direct Selecti A double-click on a Repeat (Radial, Grid or Mirror) with the Selection tool enters isolation mode on it ([isolate objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/select-objects/isolate-objects.html): a double-click with the Selection tool isolates an object; [repeat patterns overview](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/repeat-patterns-overview.html): every instance follows edits to the original object); there the source art is selected and edited as usual (a double-click on type in it switches to the Type tool on that text) and every copy follows, with the repeat's settings kept (#5054). Test: `tests::repeat_edit::a_double_click_on_a_repeat_isolates_it_to_edit_its_text`. +## Spell check engine (2026-11-08) + +Edit › Spelling has the commands its Check Spelling dialog will use, as the public documentation describes checking the document's text, suggesting replacements and changing a word ([Check and correct spelling](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/check-and-correct-spelling.html)). `text.spelling.check` lists the words the dictionaries don't know in every visible, unlocked type object, or in the ones given by `ids`, in paint order, each run checked in its Character panel Language (English when it has none; nothing in a language without a dictionary). Each word comes with its object and byte range, which `text.editRange` changes as one undo step. `text.spelling.suggest` gives one word's suggestions. Both come from craft-proof in [storytold/craft-libs](https://github.com/storytold/craft-libs), which checks each paragraph on its own. English words come from the first dictionary craft-proof finds: a Hunspell dictionary in the user's dictionaries folder, the SCOWL dictionary for that English built in from craft-i18n, a system Hunspell dictionary, and last the public-domain Moby word list built into craft-proof (#4936; `tests/spelling.rs`). The Check Spelling dialog, Auto Spell Check and Edit Custom Dictionary remain, so the Edit › Spelling menu items are still placeholders. + ## Control bar: Paragraph section while type is selected (2026-11-06) With type selected, the Control bar shows a Paragraph: link and the Align left, Align center and Align right buttons after its Character controls, as the public documentation has the Control panel format paragraphs while the Type tool is in use, with a way to the Paragraph panel ([Paragraph panel overview](https://helpx.adobe.com/illustrator/using/formatting-paragraphs.html)), and its alignment buttons align text as the Paragraph panel's do ([Align text](https://helpx.adobe.com/illustrator/using/align-text.html)). A button aligns the selected type (`text.setStyle {justify}`, one undo step) and the shared alignment shows selected; the link opens the Paragraph panel in a popover under it, as Character: opens the Character panel. Keeping to the three alignments that don't justify is our choice, for the bar's room; the popover has all seven. Short of room the bar leaves controls out from its right end: the inline X/Y/W/H fields, the Distribute and Align buttons, area type's vertical alignment, then these buttons and last the link, keeping room for the Transform link; on a 1440 pt bar with one point type object, the buttons show (pinned by a headless test, #5372). @@ -1783,6 +1897,14 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-09 | minor | File formats: EPS import: the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. | +| 2026-11-08 | minor | Type advanced: spell check engine, `text.spelling.check` lists unknown words per run language at `text.editRange` byte offsets and `text.spelling.suggest` gives suggestions, from craft-proof (#4936); evidence added; the dialog, Auto Spell Check and the custom dictionary remain; scores and hours unchanged. | +| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. | +| 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type keeps its paragraphs' tab stops, and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); evidence added; scores unchanged. | +| 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type from the text document lays its lines out closer to the file's page: auto leading per line at the paragraph's share, the paragraph's hyphenation options, possibly more of the last lines the file sets in an area frame of its own, and a line a soft return ends justified (#6137); evidence added; scores unchanged. | +| 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-08 | minor | Panel numeric wheel turns do not seed canvas zoom glides; corrected the disabled-field scroll description (#5949). Scores and estimates unchanged. | +| 2026-11-07 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | | 2026-11-08 | minor | Consolidated the four current Guides feature-area rows using existing global/artboard ruler mode, video ruler and status-bar evidence; remaining gaps, score and estimates unchanged. The earlier detailed missing-items table remains historical. | | 2026-11-07 | minor | Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates 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. | @@ -1833,12 +1955,10 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-31 | minor | Selection tools: with Alt held at the press, a Direct Selection marquee selects every path it touches whole and keeps the Group Selection pointer (#4890); a press on a selected object's centre point selects the object whole and a drag from it moves it (#4891); regression tests in `direct/tests.rs`; scores and estimates unchanged. | | 2026-10-31 | minor | Arrange Tools add-on (a VectorCraft addition): evidence added; scores unchanged. | | 2026-10-31 | minor | Guides and snapping: Snap to Perpendicular and Snap to Tangent apply to an existing line's end dragged with Direct Selection, as the public pages describe for editing existing lines (the end anchor of a straight segment, not mid-line anchors or curved segments); a regression test covers both snaps, both toggles and non-snapping controls (#2530); scores and estimates unchanged. | -| 2026-10-30 | minor | File formats: `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout) in its state block after its record, so its steps knock each other out as the page draws them; a user's file comes in 0.6% from its page instead of 1.3%. Scores and estimates unchanged. | | 2026-10-31 | minor | Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together; evidence added; scores unchanged. | | 2026-10-31 | minor | UI chrome: Units › East Asian Type acts (#394): Q and Ha (a quarter of a millimetre each) are units that preference offers; 4 Preferences options remain stored but unused; evidence added; score and hours unchanged (estimated). | | 2026-10-31 | minor | Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path; evidence added; scores unchanged. | | 2026-10-30 | patch | Performance: opaque solid-fill knockout paths avoid redundant erase/add compositing in hard-edge frames, including supersampled raster export; analytical antialiasing keeps its existing rounding; scores and estimates unchanged | -| 2026-10-30 | minor | File formats: `.ai`/EPS editing-data fidelity from an audit of a user's files: All Caps and Small Caps (#4708); placed files at their box's corner, kept where they draw closer to the page (#4664, #4723, #4742); pages cropped to the art, with `UserUnit` (#4658, #4727); gradient mesh corners with handle weights of 0 and the state written after a mesh (#4738, #4744, #4748); type on a path spaced as the file lays it out (#4743); synthetic regressions in `crates/eps`; evidence added; scores unchanged. | | 2026-10-31 | minor | Path Precision Tools add-on (a VectorCraft addition): live Path Effects; evidence added; scores unchanged. | | 2026-10-31 | minor | Artwork Cleanup add-on (a VectorCraft addition): text tools; evidence added; scores unchanged. | | 2026-10-30 | minor | Color Effects add-on: live Duotone (one to four inks, spot tones kept) and Print Ink Fixes; scores and estimates unchanged | @@ -1929,14 +2049,6 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); area score unchanged (49%, estimated). | | 2026-10-22 | minor | File formats: Affinity import gains master pages, tables, paragraph formats, text scale, shadows, glows, outlines (also on stroked shapes), overlays, underlines, strikethrough and editable embedded documents, strokes styled None left out, CMYK pixel layers through the document's CMYK profile, text placed as Affinity places it (AF.5, from storytold/designcraft#291); synthetic, corpus and local-thumbnail tests; score and hours unchanged (estimated). Hostile files stay bounded: one budget for the whole read (layers, pixels, characters, table cells, embedded documents included), a work budget for overlays, capped master page copies, objects past `MAX_COORD` left out; the import fuzzer and limit tests cover tables, paragraphs, effects, overlays, master pages, CMYK pixels and embedded documents. | | 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. | -| 2026-10-22 | minor | Artboard Options honors Scale Artwork with Artboard for preset, orientation and typed-size changes; both Fits keep artwork stationary; scores and estimates unchanged. | -| 2026-10-22 | minor | Colour and gradients: Illustrator 30.0's gradient Method, Classic or Perceptual (Gradient panel menu, canvas and raster exports, SVG/PDF as images at the raster effects resolution, native file); score and hours unchanged (estimated). | -| 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. | -| 2026-10-22 | minor | Dimension tool: in a CMYK document a dimension's lines, arrowheads and label are drawn in CMYK, black as K only (`0 0 0 1`) and other colours through the active colour settings, instead of an RGB black that printed as a four-ink rich black (#3233); RGB documents unchanged; `dimensions::dimension_colours_follow_the_document_colour_model`. Scores and hours unchanged. | -| 2026-10-22 | minor | Colour and gradients: Illustrator 30.0's gradient Dither (Gradient panel box, canvas and raster exports, SVG/PDF as images at the raster effects resolution, native file); score and hours unchanged (estimated). | -| 2026-10-22 | minor | #3546: `--automation-read-root` and `--automation-write-root` may each be given more than once (`vectorcraft-cli mcp` and the desktop app); a path inside any root of its kind is allowed, every confinement check unchanged. Scores and estimates unchanged. | -| 2026-10-22 | minor | #3147: `vectorcraft-cli run --commands FILE` (or `-`) reads the steps from a JSON file or standard input (an array or JSON Lines, at most 64 MiB and 100,000 steps), among `--cmd` and `--export` in order; errors name the step. Scores and estimates unchanged. | -| 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); area score unchanged (49%, estimated). | | 2026-10-22 | minor | Font pickers list recently applied families first (#3515), using the existing recent-font limit; search, filters, previews and keyboard selection share the same rows. Scores and estimates unchanged. | | 2026-10-21 | minor | Plug-ins: filters keep data with the document (`documentData`), as its foreign data, so the native file needs no section of its own (G17); engine and unit regressions. Scores and hours unchanged. | | 2026-10-21 | minor | Print: PDF/X-1a and PDF/X-3 flattening in a CMYK document rasterizes as ink amounts, so CMYK colours under an image with see-through pixels keep their numbers, K included, instead of going through RGB (#2160); own-fixture test; scores and hours unchanged (estimated) | @@ -1949,6 +2061,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-21 | minor | Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog. Scores and estimates unchanged. | | 2026-10-21 | minor | Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls. Scores and estimates unchanged. | | 2026-10-21 | minor | Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls. Scores and estimates unchanged. | +| 2026-10-21 | minor | SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts. Scores and estimates unchanged. | | 2026-10-21 | minor | Snap to Glyph (#3124) removed from View navigation missing lists and menu stubs; scores and hours unchanged at this rounding | | 2026-10-21 | minor | Type core: System Layout (Character panel menu, whole document, off by default) is done; scores and hours unchanged | | 2026-10-22 | minor | Ruby (ふりがな) for live text: `type.ruby` sets/removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); scores and hours unchanged | diff --git a/docs/three-d.md b/docs/three-d.md index bb0995315..f3088cc8d 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -17,6 +17,14 @@ Preview changes are temporary until OK; Cancel restores the document; OK is one 3D effect: edit its options in the Appearance panel or the 3D and Materials panel, or remove it before applying another. +Each solid follows its shape as it is filled. Separate and nested contours are built as drawn. Contours that cross, +touch or run along each other or themselves, as type often draws a glyph, are first merged into one outline by +[`vectorcraft-pathops`](../crates/pathops/README.md), keeping every contour but the numerical debris a boolean leaves. +Whether contours meet is judged on the outline as it is followed (see the curve tolerance below), so curves that +overlap by less than that are built as drawn. All three effects share pathops' +[limits](../crates/pathops/README.md#limits): a shape it refuses is built from its contours as drawn, where it can be. +An open subpath is closed by a straight line either way. + ## 3D and Materials panel **Window › 3D and Materials** opens a panel that edits the 3D effect of the selection without a dialog, as the @@ -228,9 +236,11 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. effect per object. - Curves are followed within 1/2000 of the shape's size (at least 0.05 pt), so a small curve takes a few points and a large one up to 32. Extrude & Bevel and Inflate take up to 2,048 outline points (a stroke's outline is - counted on its own), Rotate (which only turns the outline) 65,536. -- **3D (Classic)** (the track cube, surface types, blend steps, map art), Turntable and 3D export are separate, - unbuilt items. + counted on its own), Rotate (which only turns the outline) 65,536. The limit is counted on the shape as drawn and, + when its contours meet, again on the merged outline, which can have more points (bars crossing in a grid trace + every hole between them); past it, the shape is too complex for the effect. +- **3D (Classic)**: Extrude & Bevel (Classic) is built, with its surface types and blend steps. Revolve (Classic), + Rotate (Classic), the track cube, map art, Turntable and 3D export are separate, unbuilt items. Release measurements and a repeatable CLI scene are in [3D lighting performance](3d-lighting-performance.md). @@ -238,6 +248,7 @@ Release measurements and a repeatable CLI scene are in [3D lighting performance] | Date | Change | |---|---| +| 2026-11-08 | Contours that cross, touch or overlap are merged by vectorcraft-pathops before Extrude & Bevel, Inflate and Rotate build a solid, sharing its limits; others are built as drawn. | | 2026-11-06 | Extrude & Bevel, Inflate and Rotate apply to groups and layers: every path, compound path and type in them turns about one shared axis (#4506). | | 2026-10-21 | Four original lighting presets, shared by all four 3D effects and commands, with effective-setting recognition and existing preview/history behavior. | | 2026-10-17 | Smooth colored Inflate lighting and surface batching; legacy white-light appearance and the 0.05 seam minimum preserved; shared light Color and multiple lights remain vector output. | diff --git a/docs/type-parity.md b/docs/type-parity.md index 010c74aa5..d79bf9cd2 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Indian-language hyphenation review and license allowlist, #4947; Bulgarian among the languages, with its quote marks and the fonts' Bulgarian forms, #5945; previously minor: Show Snap to Glyph Options scrolls to the options, with a Snap to Glyph checkbox, #5273; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -19,19 +19,19 @@ against Illustrator's public documentation; **estimated** unless noted. | Feature | Status | Notes | |---|---|---| | Mouse wheel over the closed font-family field | done | #5253: Character, Properties and Control step selected text through the family list using saved font filters (excluding search text), one undo step per notch; the style is matched and the list stops at either end. Escape and leaving the field keep the change. Order, bounds and Undo grouping are VectorCraft's choices; scores unchanged. | -| Soft return (forced line break, Shift+Enter) | done | Shift+Enter inserts a soft return (U+2028), breaking the line without starting a new paragraph; paragraph spacing (space before/after) and first-line indents apply only at paragraph ends; continuation lines in lists do not repeat the marker; soft return lines in justified text do not spread (#2754) | +| Soft return (forced line break, Shift+Enter) | done | Shift+Enter inserts a soft return (U+2028), breaking the line without starting a new paragraph; paragraph spacing (space before/after) and first-line indents apply only at paragraph ends; continuation lines in lists do not repeat the marker; in justified text a line a soft return ends is justified as a wrapped line is, only the paragraph's last line keeping its alignment, as Illustrator's own layout in a user's `.ai` sets it (evidence: `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`; first done the other way in #2754) | | Native font-family labels | partial | With Show Font Names in English off, localized legacy names are used when typographic names contain no native label (#2208); loaded-font and catalog-only regressions. Choosing among multiple native languages is unchanged. | -| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802), and Cmd+Enter (Ctrl+Enter on Windows and Linux) does the same unless a command is bound to it; `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | +| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches back to the tool whose double-click on type chose the Type tool (kept until another tool is chosen or another document or window becomes active, so later edits with the Type tool return there too), or to the Selection tool when there is no such tool, as when the Type tool was chosen from the toolbar or after such a switch (#1802), and Cmd+Enter (Ctrl+Enter on Windows and Linux) does the same unless a command is bound to it; `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; Type › Type Orientation sets every frame of a thread, and a thread's frames share one writing direction; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | | Area Type Options (rows, columns, inset, first baseline, Text Flow by rows or by columns, Auto Size, Shrink Text to Fit) | done | Text Flow fills the cells down each column first (By Columns, the default, as files from before it open) or across each row first (By Rows), in the dialog and `text.areaOptions {flow}` (#4978); the commands that make or restyle type say when it does not fit its frame; MCP `add_text` takes Auto Size and Shrink Text to Fit (#1471) | | Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444); a thread holds area type and type on a path in any mix, as Illustrator links type in an area or along a path ([help](https://helpx.adobe.com/illustrator/using/manage-text-area.html)) | | Threading from the ports: selected area type and type on a path show an in port and an out port, empty, with an arrow on a side where the frame is threaded, or with a red "+" in the out port when the text doesn't fit; a click on either port loads the text; a click on a closed path, area type or type on a path threads into it, a click on empty canvas makes a frame of the same kind, shape and size there, a drag draws a rectangular frame; the frame placed comes after the clicked one from its out port and before it from its in port (the story then starts there); Escape or another tool cancels; a double-click on a threaded port breaks the thread there | partial | #2572: the Selection tool, `text.thread.add {from, to?, rect?, at?, before?}`, as the reference app's help describes ports and threading ([archived help, Threading text between objects](https://web.archive.org/web/20210119051048/https://helpx.adobe.com/illustrator/using/creating-text.html), [create text threads](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/create-text-threads-between-objects.html)); area type's in port sits on the left side below the top-left corner, type on a path's just before its start bracket; Show Text Threads draws its lines between the same ports; with more than 1,000 such objects selected no ports are drawn. a double-click on a port where the frame is threaded breaks the thread there, the story flowing into the frames before the break and the frames after it left empty, still threaded with each other when two or more (`text.thread.break`, [archived help](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html)). Remaining: the ports only take clicks with the Selection tool, and the loaded cursor borrows the I-beam until it's drawn | | Type on a Path effects and options; brackets | done | #429; Type on a Path Options has a Preview checkbox (#5360, as public tutorials of the dialog show) | -| Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing | +| Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing. Double-clicking a user character style's row opens its Character Style Options, and the dialog keeps a fixed width while its sections scroll when the window is shorter, so its buttons stay on screen; a paragraph style's row opens its Paragraph Style Options, the rename dialog the panel menu opens. Evidence: `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options`, `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list` | | Copy or Cut selected characters, then paste as separate text art | done | A Type-range copy keeps the selected characters' font family, size and color when pasted on the canvas after leaving text editing, in the same document or another. On desktop the clipboard offers plain text to text consumers and rich art for canvas Paste; Paste Without Formatting while editing uses the destination text's style. `tests::clipboard::type_range_copy_stays_styled_when_pasted_as_art` (#5636) | | Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | | Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | | OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, one of which a double-click draws for that character (`text.setGlyphAlternate`); the glyph-cell pop-up of alternates is not there yet | -| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). | +| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). | | Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG | | Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending | | Variable fonts | done | named instances as styles; Character panel › Variable Font sets each axis (`fontAxes`); the font menu's Font Technology › Variable lists only variable font families, with the script, kind and ★ filters ([Find, apply, and adjust variable fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-apply-and-adjust-variable-fonts.html)); a family with a variable file beside static ones counts as variable. Evidence: `font_menu::tests::the_variable_filter_lists_only_variable_families`, `tests_axes::a_family_with_variation_axes_is_variable`, `tests_sysfonts::a_family_with_a_variable_file_beside_static_ones_is_variable` | @@ -45,11 +45,11 @@ against Illustrator's public documentation; **estimated** unless noted. | Hyphenation… and Justification… options | done | Eleven Indian languages use attributed patterns and preserve syllables in both composers (#4947). Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310). Justification… (`ui.justification`, `text.setFormat`, #2755) sets Word Spacing, Letter Spacing and Glyph Scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification; Desired applies to every line, justified lines and the Every-line Composer use the ranges. Vertical type isn't glyph scaled; type on a path doesn't use them. Evidence: `crates/text/src/tests/justify.rs`, `dialogs::justification::tests` | | Roman Hanging Punctuation | done | Paragraph panel menu › Roman Hanging Punctuation, `text.setFormat {romanHangingPunctuation}` (#2756): per paragraph, Optical Margin Alignment's punctuation and amounts without the letters; paragraph styles carry it. Evidence: `tests::roman_hanging_punctuation_hangs_punctuation_but_not_letters`, `typecmd::area_tests::roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on` | | Hyphenation… and Justification… options | done | Eleven Indian languages use attributed patterns and preserve syllables in both composers (#4947). Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310). Justification… (`ui.justification`, `text.setFormat`, #2755) sets Word Spacing, Letter Spacing and Glyph Scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification; Desired applies to every line, justified lines and the Every-line Composer use the ranges. Vertical type isn't glyph scaled; type on a path doesn't use them. Auto Leading also sets the Leading the Character panel shows for Auto type, the leading Alt+↑/↓ (`type.step`) steps from, per paragraph, and the lines Fill with Placeholder Text counts. Evidence: `crates/text/src/tests/justify.rs`, `dialogs::justification::tests`, `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading` | -| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877); Ctrl-dragging a stop (Command on macOS) moves all the stops together | +| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877); Ctrl-dragging a stop (Command on macOS) moves all the stops together; in right-aligned area type the text before a line's last tab sits at its stops, from the left edge, and the text after it against the right edge, as Illustrator's own layout in a user's `.ai` sets a price list (evidence: `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`; centred and justified lines are still aligned as a whole) | | Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | -| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | missing | menu stubs; needs an openly licensed dictionary | +| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | partial | the engine (#4936): `text.spelling.check` lists the unknown words of the document's type, each run in its Language, at the bytes `text.editRange` changes, and `text.spelling.suggest` gives suggestions, both from craft-proof, each paragraph on its own (English from the first dictionary it finds: a Hunspell dictionary in the user's dictionaries folder, the SCOWL dictionary built in from craft-i18n, a system Hunspell dictionary, and last the public-domain Moby word list built into it; other languages from the dictionaries craft-proof finds for them). The Check Spelling dialog, Auto Spell Check and Edit Custom Dictionary remain (the Edit › Spelling items are still off). Evidence: `crates/engine/src/tests/spelling.rs` | | Smart Punctuation, Change Case, Find & Replace | done | | -| Alt + numeric-keypad codes while typing (Windows: `Alt+0176` → °) | partial | #5108: on Windows the character Windows posts when Alt comes up after keypad digits is typed at the caret like any other, in the typing's undo step, and the keypad digits typed with Alt run no shortcut (`windows_alt_codes` tests; not yet tried on a Windows machine). `type.insert {code}` (Windows Alt+0 code, Windows-1252 for 128–159) and `{unicode}` (hex code point) insert a character by its code (`menucmds::insert_a_character_by_its_code`). macOS and Linux have no such system convention and egui reports a keypad digit as the top-row digit, so there it does nothing; a floating panel window doesn't receive the character | +| Alt + numeric-keypad codes while typing (Windows: `Alt+0176` → °) | partial | #5108: on Windows the character Windows posts when Alt comes up after keypad digits is typed at the caret like any other, in the typing's undo step, and the keypad digits typed with Alt run no shortcut. With NumLock off the keypad keys (Insert, End, arrows, Clear, Home, Page Up/Down without the extended-key flag) count as digits too and don't move the caret (`windows_alt_codes` unit tests; neither NumLock on nor off has been tried on a Windows machine yet). `type.insert {code}` (Windows Alt+0 code, Windows-1252 for 128–159) and `{unicode}` (hex code point) insert a character by its code (`menucmds::insert_a_character_by_its_code`). macOS and Linux have no such system convention and egui reports a keypad digit as the top-row digit, so there it does nothing; a floating panel window doesn't receive the character | | Caret: the arrows and Delete step over whole clusters (Thai consonant with its marks, combining accents, emoji); Backspace removes one character | done | in text with right-to-left characters the arrows move in visual order and stop on both sides of a character drawn with no width, such as a zero width non-joiner | | Touch Type tool | done | moves, scales and rotates one character through its kerning, baseline shift, scales and rotation (`text.touchType`); a character with a preceding logical character on the same line can move along the line in either reading direction; the first logical character moves only up and down. Positive `dx` follows reading order. These direction rules are VectorCraft's choices. | | Retype (identify fonts in outlines/images) | missing | Illustrator's uses a cloud model | @@ -61,13 +61,15 @@ against Illustrator's public documentation; **estimated** unless noted. | Middle Eastern: Middle Eastern composers, split caret | missing | Character Direction is done; editable bidirectional SVG text remains incomplete. | | 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: East Asian Type units (Q, Ha) | done | Preferences › Units › East Asian Type (`unitsAsianType`, Points by default) offers every unit and Q and Ha, a quarter of a millimetre each ([Scripting Guide](https://ai-scripting.docsforadobe.dev/scripting/measurementUnits/): 0.709 pt per Q; [InDesign's unit notation](https://helpx.adobe.com/in/indesign/desktop/layout-and-grid-tools/rulers-and-measure-tools/change-ruler-measurement-units.html): `q`, `h`). The public pages do not say which fields follow it, so no field uses it yet and that waits for a source. Evidence: `q_and_ha_are_a_quarter_millimetre`, `east_asian_type_unit_*` | | 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. Tate-chu-yoko Settings… (`ui.tateChuYokoSettings`, `{tcyUpDown, tcyLeftRight}` in points) moves a marked block up or down and right or left as one piece. Evidence: `tests_vertical::manual_tate_chu_yoko_sets_marked_characters_across_one_em`, `tests_vertical::tate_chu_yoko_settings_move_a_marked_block_up_down_left_and_right`, `typecmd::tate_chu_yoko_is_a_character_attribute_saved_only_when_on`, `typecmd::tate_chu_yoko_settings_are_character_attributes_saved_only_when_set`, `dialogs::tate_chu_yoko::tests` | | 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` | +| CJK: composite fonts | partial | Type › Composite Fonts… (`type.compositeFonts.set`), with Show East Asian Options: a font made of a font per class of character (Kanji, Kana, Punctuation, Symbols, Roman, Numerals), each with its own style, size and baseline in percent of the type's size, Kana, Roman and Numerals with horizontal and vertical scale, and Kana with Scale at Center; New, Delete Font, Import… and Export… (`.vccomposite`, `type.compositeFonts.import` and `.export`) ([Creating composite fonts](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347)). Composite fonts are listed first in the font menus, saved with the document, and their type is laid out per class; SVG and legacy `.ai` write it as outlines. Not yet: custom character sets, the Units menu, Show Sample. Evidence: `text::composite::tests::characters_fall_in_their_classes`, `tests::composite::composite_type_draws_each_class_in_its_font`, `compositefonts::tests`, `dialogs::composite_fonts::tests` | | CJK: ruby | partial | ruby (ふりがな) for live text (`type.ruby`): a reading over a byte range, above (right of the column in vertical type) or below, start/centre/end alignment, size, overhang or squeeze; saved natively, laid out with its line growing, drawn on the canvas, in SVG and PDF (as outlines where ruby is present), in EPS, EMF/WMF and legacy `.ai` (legacy `.ai` writes type with ruby as outlines), and kept by Create Outlines, Expand Appearance and flattening, each glyph where the layout puts it (`typecmd::ruby_tests::create_outlines_keeps_the_ruby_where_it_is_laid_out`) | #633 | -| Language per character (Character panel › Language, character styles), text shaped in it | done | `text.setFormat` / `text.setRangeStyle {language}` by name or BCP 47 tag, one of Document Setup's languages (Hindi, Marathi, Gujarati, Bengali, Punjabi, Tamil, Telugu, Kannada, Malayalam, Odia and Urdu added, #4943; Assamese and Sanskrit added, #4947; Serbian: Cyrillic, Serbian: Latin and Croatian); the shaper gets the tag, so the font's language forms apply (OpenType `locl`: Turkish keeps `fi` apart and the dot on its i, #4940); new type takes the document's language; saved as the tag only when set, carried by character styles (#4946). English and eleven Indian languages hyphenate by run language; Sanskrit automatic hyphenation awaits allowlisted patterns (#4947). Not yet: spelling by it, `.ai`/PDF import of a text's language, paragraph styles. Evidence: `features::tests::language_picks_the_fonts_forms`, `features::tests::serbian_gets_the_fonts_serbian_forms`, `typecmd::area_tests::language_is_a_character_attribute_saved_only_when_set`, `tests::typo::indic_language_runs_hyphenate_with_both_composers`, `hyphen::tests::sanskrit_without_allowlisted_patterns_uses_only_exceptions` | -| Language per character (Character panel › Language, character styles), text shaped in it | done | `text.setFormat` / `text.setRangeStyle {language}` by name or BCP 47 tag, one of Document Setup's languages (Hindi, Marathi, Gujarati, Bengali, Punjabi, Tamil, Telugu, Kannada, Malayalam, Odia and Urdu added, #4943; Serbian: Cyrillic, Serbian: Latin and Croatian; Bulgarian, #5945); the shaper gets the tag, so the font's language forms apply (OpenType `locl`: Turkish keeps `fi` apart and the dot on its i, #4940); new type takes the document's language; saved as the tag only when set, carried by character styles (#4946). Not yet: hyphenation and spelling by it, `.ai`/PDF import of a text's language, paragraph styles. Evidence: `features::tests::language_picks_the_fonts_forms`, `features::tests::serbian_gets_the_fonts_serbian_forms`, `typecmd::area_tests::language_is_a_character_attribute_saved_only_when_set` | +| Language per character (Character panel › Language, character styles), text shaped in it | done | `text.setFormat` / `text.setRangeStyle {language}` by name or BCP 47 tag, one of Document Setup's languages (Hindi, Marathi, Gujarati, Bengali, Punjabi, Tamil, Telugu, Kannada, Malayalam, Odia and Urdu added, #4943; Assamese and Sanskrit added, #4947; Serbian: Cyrillic, Serbian: Latin and Croatian); the shaper gets the tag, so the font's language forms apply (OpenType `locl`: Turkish keeps `fi` apart and the dot on its i, #4940); new type takes the document's language; saved as the tag only when set, carried by character styles (#4946). English and eleven Indian languages hyphenate by run language; Sanskrit automatic hyphenation awaits allowlisted patterns (#4947). Not yet: `.ai`/PDF import of a text's language, paragraph styles. Evidence: `features::tests::language_picks_the_fonts_forms`, `features::tests::serbian_gets_the_fonts_serbian_forms`, `typecmd::area_tests::language_is_a_character_attribute_saved_only_when_set`, `tests::typo::indic_language_runs_hyphenate_with_both_composers`, `hyphen::tests::sanskrit_without_allowlisted_patterns_uses_only_exceptions`; spelling is checked in each run's Language (`text.spelling.check`, #4936) | +| Language per character (Character panel › Language, character styles), text shaped in it | done | `text.setFormat` / `text.setRangeStyle {language}` by name or BCP 47 tag, one of Document Setup's languages (Hindi, Marathi, Gujarati, Bengali, Punjabi, Tamil, Telugu, Kannada, Malayalam, Odia and Urdu added, #4943; Serbian: Cyrillic, Serbian: Latin and Croatian; Bulgarian, #5945); the shaper gets the tag, so the font's language forms apply (OpenType `locl`: Turkish keeps `fi` apart and the dot on its i, #4940); new type takes the document's language; saved as the tag only when set, carried by character styles (#4946). Not yet: hyphenation by it in languages other than English and the eleven Indian languages above, `.ai`/PDF import of a text's language, paragraph styles. Evidence: `features::tests::language_picks_the_fonts_forms`, `features::tests::serbian_gets_the_fonts_serbian_forms`, `typecmd::area_tests::language_is_a_character_attribute_saved_only_when_set`; spelling is checked in each run's Language (`text.spelling.check`, #4936) | | No Break | done | Character panel menu (`text.setFormat` / `text.setRangeStyle {noBreak}`): the marked characters stay on one line, including across a space; a run that does not fit after earlier text moves together, and a run longer than the line stays together. Evidence: `layout::wrapping_tests::no_break_keeps_marked_characters_on_one_line`, `tests::no_break_keeps_marked_words_on_one_line`, `typecmd::no_break_is_a_character_attribute_saved_only_when_on` | | Inline symbols in text | beyond Illustrator | not scored | | Align Stroke (inside, outside) on live type's character stroke | beyond Illustrator | not scored; `stroke.set {align}` and `text.setRangeStyle {strokeOptions: {align}}`; saved natively, drawn around the glyphs on the canvas and in PDF, EPS and SVG (live text, stroke on the glyph outlines); visual bounds grow with it (#2294) | @@ -79,11 +81,18 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | Spell check engine (#4936): `text.spelling.check` lists unknown words per run language at `text.editRange` byte offsets and `text.spelling.suggest` gives suggestions, from craft-proof; the dialog, Auto Spell Check and the custom dictionary remain. Spelling noted in the Language per character rows, which no longer list it as not yet done; their hyphenation note names the languages that still lack it. Scores and estimates unchanged. | +| 2026-11-07 | minor | Tabs: in right-aligned area type, the text before a line's last tab sits at its stops, measured from the left edge, and the text after it (none, when the tab ends the line) against the right edge; the line was aligned as a whole, which moved its start off the left edge. Illustrator's own layout in a user's `.ai` (used locally, not committed) sets its price lists so. Centred and justified lines keep their alignment as a whole. Scores and estimates unchanged. | +| 2026-11-07 | minor | Soft return: in Justify Left, Center or Right, the line a soft return ends is justified as a wrapped line is; only the paragraph's last line keeps its alignment. Illustrator's own layout in a user's `.ai` (used locally, not committed) justifies those lines. Scores and estimates unchanged. | +| 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | +| 2026-11-07 | minor | Alt + numeric-keypad codes with NumLock off: the keypad keys arrive as Insert, End, arrows, Clear, Home and Page Up/Down, which Windows still composes into the code; with Alt held they now count as digits and are taken out of the frame instead of moving the caret, while the separate navigation keys (extended-key flag) keep their meaning (follow-up to #5939). Scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-06 | minor | Alt + numeric-keypad codes (`Alt+0176` → °) type their character at the caret on Windows, and `type.insert` inserts a character by `code` or `unicode` (#5108); scores and estimates unchanged | | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: each glyph's box gives four corners a drawn point lands on, also on a curved side (`B`), and its box and edges turn with rotated type (#5112); scores and estimates unchanged | | 2026-11-05 | minor | Character panel › Show Snap to Glyph Options scrolls the panel down to the Snap to Glyph section, which comes after every character attribute and could sit out of sight, and the section starts with a Snap to Glyph checkbox (View › Snap to Glyph) so its dimmed options can be turned on there (#5273); scores and estimates unchanged | | 2026-11-05 | minor | Release Type on a Path joins the Type Tools add-on: type on a path splits into point type and its path (#5532, beyond Illustrator). Scores and estimates unchanged. | +| 2026-11-04 | minor | Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); scores and estimates unchanged. | +| 2026-11-04 | minor | Typographer's Quotes: a lone typed `”` (U+201D) is set like a straight `"` where a quote opens (at the start, after a space), taking the document's opening quote, and stays `”` where one closes: the macOS Serbian Cyrillic layout has no straight quote and types `”` on Shift+2, which now gives „…” (#5247). `“ „` and the single quotes `‘ ’ ‚` stay as typed (that layout's apostrophe key types `’`, and `’90s` keeps its apostrophe); pasted text keeps its quotes; with the option off `”` stays as typed. Evidence: `docsetup::a_typed_closing_double_quote_opens_where_a_quote_opens`; scores unchanged | | 2026-11-04 | minor | A Type-range Copy or Cut publishes styled art alongside plain text, so a later canvas Paste keeps its character formatting after editing ends (#5636). Scores and estimates unchanged. | | 2026-11-03 | minor | Glyphs panel adds Alternates for Current Selection. A chosen alternate changes one character's drawn glyph while keeping its Unicode text; character-style capture does not copy that per-character choice. Scores and estimates unchanged. | | 2026-11-04 | minor | Find Font's Replace With Font lists family and style rows, one click setting both, with a search over them; the list scrolls apart from the document's font list and refreshes after each replacement (#4972); scores and estimates unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b04d68162..00b5260cd 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,9 +1,11 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence +Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow keys still require focus; modifiers, units, limits, mixed values and existing rounding stay in the shared stepping path. Headless regressions in `tests/numfields.rs` cover hovered unfocused fields, focused fields, trackpad accumulation and panel scroll exclusion. Scores and estimates unchanged. + | 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. @@ -15,6 +17,8 @@ Group Selection keeps an already selected component selected during a drag; a co Creating a gradient from solid or empty paint uses the document's color model for its default stops, including drawing its vector with the Gradient tool. Explicit `keepModel: true` retains RGB defaults, and existing gradients keep their own colors. Scores and estimates are unchanged. +Window → Help Bar toggles the bottom tool-help strip independently of the status bar. Its visibility is saved with workspaces and UI preferences; Presentation Mode hides both bars. Scores and estimates are unchanged. + **Feature evidence last updated:** 2026-11-03. Enclosing Lasso is an add-on under Object › Plug-ins with its own heading in All Tools. It has @@ -39,6 +43,10 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | +| 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | +| 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. | +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-10-30 | minor | Home screen: welcome, New / Open and Community are centred and the large Discord button uses the theme accent, part of #4684; scores and estimates unchanged (estimated) | | 2026-11-06 | minor | Snap to Grid: a straight segment dragged with Direct Selection lands its anchors on the grid, not the pressed point (#3835); scores and estimates unchanged | | 2026-11-06 | minor | Drop Shadow: a swatch beside Color follows the hex and a double-click opens the Color Picker over the dialog, whose OK fills Color (#5364); scores and estimates unchanged | @@ -71,6 +79,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-03 | minor | Enclosing Lasso provides whole-object enclosure selection as an add-on with no default shortcut or Basic toolbar slot. Scores and estimates remain unchanged. | | 2026-11-03 | minor | Transform previews valid typed values while numeric fields remain focused. Effect dialogs use the object, group, or layer targeted in Appearance. | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | +| 2026-11-03 | minor | Help Bar has its own Window menu toggle and workspace visibility setting. Scores and estimates unchanged. | | 2026-10-26 | minor | Pencil Fidelity goes down to 0.01 pt (dialog, tool and engine; Smooth keeps 0.05) on a logarithmic slider across the Tool Options row, with hundredths in the field and key steps that fit the range (#3328); scores and estimates unchanged (estimated) | | 2026-10-22 | minor | Reconcile upstream documentation after review F-3; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | | 2026-10-22 | minor | Reconcile upstream documentation after review F-2; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | @@ -130,7 +139,7 @@ tools, never checked side by side) isn't captured row by row. | Click the first anchor to close; the drag shapes the closing curve | done | | | Click the last anchor to retract its outgoing handle; drag from it to pull a new one | done | | | Alt over a handle, anchor or segment acts as the Anchor Point tool | done | | -| Cmd/Ctrl lends the last-used selection tool | done | the path being drawn continues afterwards; with Direct Selection (the default until a selection tool is chosen) a Cmd drag moves an anchor of the path being drawn, moves a straight segment or bends a curved one, one undo step each, and a Cmd-click away from art ends the path; with Selection the hidden bounding box of the path being drawn takes no press, so the drag moves the path (#4980) | +| Cmd/Ctrl lends the last-used selection tool | done | the path being drawn continues afterwards; with Direct Selection (the default until a selection tool is chosen) a Cmd drag moves an anchor of the path being drawn, moves a straight segment or bends a curved one, one undo step each, and a Cmd-click away from art ends the path; with Selection the hidden bounding box of the path being drawn takes no press, so the drag moves the path (#4980); anchors or segments the lent Direct Selection picks are what Copy and Cut take, as with Direct Selection chosen, while Copy with the Pen's own last anchor selected takes the whole path (#6123) | | Click an open path's end to continue it; click another path's end to join | done | #776 | | Rubber Band preview | done | Preferences option | | Auto Add/Delete on a selected path; Shift or the preference disables it | done | | @@ -167,6 +176,7 @@ tools, never checked side by side) isn't captured row by row. | Behaviour | Status | Notes | |---|---|---| +| Deleting segments or anchors that disconnect an ordinary path creates independent selected objects | done | #3380, deletion only: first piece retains its ID, styles and transforms carry over in sibling order, bounds-fitted gradients keep their original placement, and one Undo restores the original. Existing multi-subpath paths and compound members stay intact. `delete_path_pieces` covers transformed/styled rectangles, cubic handles and Undo/Redo. Endpoint highlighting remains open. | | Click/Shift-click anchors; marquee toggles with Shift | done | #483 | | Align selected anchors to their bounds or the artboard; repeating an alignment that moves nothing keeps Undo, Redo and saved state | done | #1180 follow-up; engine regressions include rounding-only moves and unchanged handles; #1641 (fixes #1636): Align and Distribute act on the anchors selected with Direct Selection, the points of one path aligning to each other and Distribute spacing them | | Align selected anchors to a key anchor | missing | #1636 | @@ -183,14 +193,14 @@ tools, never checked side by side) isn't captured row by row. | Drag a handle with Shift (45° about its anchor), Alt (move one handle alone) | done | handles snap to smart guides; handle and anchor drags no longer stall when the pointer leaves and re-enters the window, and a move in the release's frame lands (#834) | | Show handles when multiple anchors are selected; Highlight anchors on mouse over; anchor and handle size | done | Preferences › Selection & Anchor Display | | Convert selected anchors to corner/smooth from the Control bar and Properties | done | | -| Cut path at selected anchors, Remove Anchor Points, Delete removes segments | done | Open-path anchor Delete removes adjoining segments without bridging the gap; targeted core and full-frame Delete/Backspace regressions pass for survivor selection and Undo/Redo. Explicit Remove Anchor Points keeps its reconnect/refit behavior; #1278: segment Delete preserves neighboring edges and endpoint anchors; a second Delete removes the remainder; explicit anchor Delete retains its behavior; #1674: Delete removes every selected anchor of a closed path and objects selected whole along with them, and keeps the remainder selected; #2241: Remove Anchor Points then selects the next anchor along the path (the one before at an open end), so it can be run again without picking each anchor | +| Cut path at selected anchors, Remove Anchor Points, Delete removes segments | done | Open-path anchor Delete removes adjoining segments without bridging the gap; #3380 (deletion only): disconnected survivors of an ordinary contour become independent selected sibling objects, while multi-subpath paths and compound members stay intact; targeted core and full-frame Delete/Backspace regressions pass for survivor selection and Undo/Redo. Explicit Remove Anchor Points keeps its reconnect/refit behavior; #1278: segment Delete preserves neighboring edges and endpoint anchors; a second Delete removes all selected surviving objects; explicit anchor Delete retains its behavior; #1674: Delete removes every selected anchor of a closed path and objects selected whole along with them, and keeps the surviving objects selected; #2241: Remove Anchor Points then selects the next anchor along the path (the one before at an open end), so it can be run again without picking each anchor | | Direct Selection and Group Selection pick only inside the isolated object in isolation mode | done | #1666 | | Group Selection picks one path of a compound path (the outer shape or a hole) and moves it on its own; a second click adds the compound path | done | #2441; Shift-click on a selected member deselects it on release | | Direct Selection, Group Selection and the Selection tool take a clipping path (a clip group's, or a layer's clipping mask) on its outline, within the selection tolerance, even where the art it clips lies over its edge | done | `HitOptions::clip_outlines`; Select Behind lists the same; the eyedropper and the tools that act on what is drawn take the art | | Alt with Direct Selection makes a marquee Group Selection's for the drag: it selects every path it touches, whole (Shift toggles them), and the Group Selection pointer stays until the release | done | #4890 | | Direct Selection: a click on a selected object's centre point (Attributes › Show Center) selects the object whole, and a drag from it moves the object in one undo step | done | #4891 | | Escape cancels a Direct Selection drag | done | #1803 (#1743) | -| Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing; the bounding box hides while a widget is dragged with the Selection tool (#1229); a round corner next to a curved side curves smoothly instead of sticking out of the shape (#1512); a corner rounds until its cut reaches its neighbours' cuts, so one corner of a square alone reaches the whole side (#2332) | +| Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239), and with every corner selected a Shift-click on a widget picks that corner alone instead of deselecting it (#5188); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing; the bounding box hides while a widget is dragged with the Selection tool (#1229); a round corner next to a curved side curves smoothly instead of sticking out of the shape (#1512); a corner rounds until its cut reaches its neighbours' cuts, so one corner of a square alone reaches the whole side (#2332) | | Corners between curved sides (#1144), Relative rounding | done | #1144 closed; Relative rounding in the Corners dialog (Relative / Absolute, radius as a percentage of each corner's maximum, kept as the shape is resized; `object.setLiveShape` `relativeRadii`, #3091, `tests_livecorners::relative_corner_rounding_keeps_fraction_on_resize`) | | Pie widgets on a live ellipse: drag one to set Pie Start or End Angle, double-click one to make the ellipse whole | done | Selection tool, with the bounding box showing | | A shape just drawn shows its bounding box and Live Shape widgets with the shape tool still active, and the selection can be moved and turned without switching tools | done | #5477: Rectangle, Rounded Rectangle, Ellipse, Polygon and Star keep the selection's bounding box, corner widgets and pie widgets on show while they draw nothing; a drag on a corner or pie widget adjusts it (arrows step the corner kind, Escape cancels), a press on the selection's centre point (Attributes › Show Center) moves the selection — the Selection tool's move arrow, snapping and Alt copy — a press just outside a corner of the box turns it about the centre (the Selection tool's rotate zone and cursor), and a press anywhere else draws (so a second shape drawn from the first one's centre moves the first: press elsewhere or deselect first). Public descriptions: [Live Shapes](https://helpx.adobe.com/illustrator/using/live-shapes.html) (live shapes adjust without switching tools) and [CreativePro, Working with Live Shapes](https://creativepro.com/?p=23921) (a widget shows in every corner on release; drag one toward the centre to round). The bounding-box handles show but scale only with the Selection tool, so a press on a corner still starts a new shape there; double-clicking a widget opens nothing with a shape tool. Tests `a_shape_tool_drags_the_selected_shapes_widgets` and `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` | @@ -257,7 +267,7 @@ tools, never checked side by side) isn't captured row by row. | Wheel over a closed font-family field in Character, Properties or Control | done | #5253: applies the next or previous family to selected text, one undo step per notch; uses saved font filters, ignores search text and stops at the list's ends. Escape and pointer departure keep the change. Other font-picking menus and modifier-wheel input keep their existing behavior. | | Arrow keys nudge by Keyboard Increment (0.001–1296 pt); Shift ×10; Alt copies | done | | | Fields commit what was typed on a click away or a move to another field, as Enter does; Escape discards it | done | #1787 | -| Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a focused field; steps, the wheel and label scrubs stop at the field's bounds (a stroke weight at 0, a miter limit at 1) | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991); bounds #3370 | +| Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a hovered field without a click; steps, the wheel and label scrubs stop at the field's bounds (a stroke weight at 0, a miter limit at 1) | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991); bounds #3370 | | Arithmetic in fields (`+ - * /`, and `%` of the value before it: `3cm*50%`, `50pt+25%`) and unit suffixes (`3 mm`, `1in`, `2p6`) | done | `Unit::parse`; mixed units in one expression not verified | | Numeric fields: focus click selects the whole value; dragging selects from the pressed character | partial | #1326; select-all waits for click release; px/pt/plain/optional fields, both directions and replacement text tested; native API input, 17 px partial replacement, Undo/Redo, saved-file round trip and screenshots verified; Tab, double-click, arrows and wheel preserved; short movements still classified as clicks can select all and remain open | | Units: pt, pc, in, mm, cm, px, ft-in, m, yd, ft; General/Stroke/Type units | done | units preference reverting to points on SVG output reported (#864) | @@ -266,7 +276,7 @@ tools, never checked side by side) isn't captured row by row. | Scrubby labels (beyond Illustrator) | done | #400; the labels that are links (the Control bar's Stroke: and Opacity:, the Properties panel's Stroke) scrub too, and a click on them still opens their panel (#4088) | | 3D Lighting Color picker, preview, reset dot and Reset all | initial | One to ten opaque RGB lights for Revolve, Extrude & Bevel, Inflate and Rotate; select, add or delete a light and edit its settings or gizmo; Standard, Diffuse, Top Left and Right select original rigs, with Custom for manual changes; Cancel restores all lights | | Dialogs open with the first field focused and selected; Enter confirms | done | on every open, not only the first (#1367); a first field that starts greyed out takes the focus once enabled (#1395) | -| Value fields: selected text readable in every brightness; a focused field follows its value when a slider or preview changes it | partial | #1490 (part of #1465: the wheel over a hovered, unfocused field still scrolls the panel), #1457 | +| Value fields: selected text readable in every brightness; a focused field follows its value when a slider or preview changes it | partial | #1490, #1457; hover wheel stepping #5949 | | Sliders in dialogs and popups (Save for Web, raster export options, Preferences, the opacity popup) show their rail, filled up to the handle | done | #1483 | | Rectangular Grid and Polar Grid click dialogs: size, divider counts with a skew slider each, Use Outside Rectangle As Frame / Create Compound Path From Ellipses, Fill Grid; kept as the tool's options, used by drags too | done | #1377. The options are the documented ones; how far a skew percentage moves the dividers and which way the radial skew turns are our own choice (the public documentation gives neither). The reference point locator is in the dialog, and a double-click on the tool opens it as the tool's Tool Options (next row) | | Double-click the Line Segment, Arc, Rectangular Grid or Polar Grid tool: its Tool Options ([Rectangular Grid tool](https://helpx.adobe.com/illustrator/using/tool-techniques/rectangular-grid-tool.html) and [Arc tool](https://helpx.adobe.com/illustrator/using/tool-techniques/arc-tool.html) step 3, [Tools](https://helpx.adobe.com/illustrator/using/tools.html)) | done | #2691. The tool's click dialog without a place to draw: Line Segment's Length, Angle and Fill Line; Arc's lengths, Reference Point, Type, Base Along, Slope and Fill Arc; the grids' Reference Point, Default Size, dividers, skews and switches. OK draws nothing and sets what the next drag draws with (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and what a click's dialog opens at (with the Length, Angle and Default Size); Cancel changes nothing (`double_clicking_the_line_arc_and_grid_tools_opens_their_tool_options`) | @@ -287,7 +297,7 @@ tools, never checked side by side) isn't captured row by row. | Artboard tool: the canvas right-click menu has Lock Artboards (Lock Selected, Lock All Except Selected, Lock All) and Unlock Artboards (Unlock Selected, Unlock All) | done | #2531; `artboard.setProps` with `locked` for each artboard named; the lock itself is #2592 | | Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool | done | Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. | | Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture | done | Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. | -| Escape while typing ends the edit, keeps the type selected and switches to the Selection tool | done | #1802 (#1686, #1656) | +| Escape while typing ends the edit, keeps the type selected and switches back to the tool whose double-click on type chose the Type tool (kept until another tool is chosen or another document or window becomes active, so later edits with the Type tool return there too), or to the Selection tool when there is no such tool, as when the Type tool was chosen from the toolbar or after such a switch | done | #1802 (#1686, #1656) | | Selection tool: selected area type and type on a path show an in port and an out port (empty, an arrow where threaded, the red "+" of overflow); a click on either loads the text cursor; a click on a closed path, on empty canvas or a drag threads the text there, after the clicked frame from its out port, before it from its in port; Escape or another tool drops it; a double-click on a threaded port breaks the thread there | partial | #2572 (`text.thread.add`, `before` for the in port); the out port sits on the frame's right side above the bottom-right corner handle and the in port on its left side below the top-left one, so the handles can still be grabbed; a double-click on a threaded port breaks the thread (`text.thread.break`, [archived help](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html)); the loaded cursor borrows the I-beam | | Shift-click selects several artboards (Artboard tool, Artboards panel); they move, Alt-copy and delete together | done | `artboard.move`/`artboard.delete` `indices` (#1139); in the panel, Shift- and Ctrl/Cmd-click work with any tool by switching to the Artboard tool (#5698); panel menu, trash and Properties share selected-set deletion; all-selected retains artboard 1 and a single board cannot be deleted, with pointer-event and Undo/Redo coverage | | Artboards panel: drag a row above or below another to reorder the artboards (as Move Up / Move Down do) | done | #4687; a line shows where the row lands, `artboard.reorder` in one undo step, the moved artboard becomes active; public forum answers describe the drag | @@ -334,6 +344,8 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po ## Panels, Control bar, Properties, context menus +- **Panel collapse controls (#5925, in part):** The small » in a floating tab-group header moves that group into the side icon rail, reusing the existing rail or creating one. An opened rail panel has the same control to fold its flyout back into the rail, including after a tab switch. Both controls preserve the tab group and its menu; other floating groups and full-width docked headers stay as they were. Pointer regressions in `crates/ui-egui/src/panel_docking/tests.rs` cover grouped tabs, reopening, separate groups, a missing rail, the menu and flyout closure after switching tabs; `native_tests.rs` covers the native-window click and removal of the empty palette. Scores and estimates unchanged. + | Interaction | Status | Evidence | |---|---|---| | Selection artwork drag hovers a visible document tab to activate it, then drops editable artwork on its canvas | done | `tests_document_drag`, `edit.dropSelection`, lossless resource and one-step Undo regressions; overflow and cross-window drag remain separate | @@ -365,7 +377,8 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Live trace result bounds and selection highlights exclude the retained bitmap; source-image views retain it | done | All TraceView modes and rotated result bounds regression-covered; ordinary hidden group behavior retained | | Image Trace panel: the Preview checkbox before Trace chooses whether a setting change traces the selected Image Trace object again at once | done | Per the public documentation, Preview shows the result of the current settings and Trace traces with them (<https://web.archive.org/web/20250831090623/https://helpx.adobe.com/illustrator/using/image-trace.html>). Preview is on by default and kept with the UI preferences; Reset to Default leaves it. Off, changes wait in the panel until Trace applies them as one undo step, or until Preview is turned on again. Changes stay with the object they were made for while another object is selected, and come back when it is selected again. `imageTrace.preview {on?}`, `ui.set {imageTracePreview}`; `panels/image_trace.rs` headless tests (`with_preview_off_a_slider_change_waits_for_trace`, `turning_preview_on_traces_the_changes_that_waited`, `preview_turned_on_with_nothing_selected_traces_when_the_object_is_selected_again`, `settings_that_wait_stay_with_their_object`, `settings_that_wait_come_back_when_their_object_is_selected_again`, `settings_that_wait_are_dropped_when_their_object_changed_since`, `preview_is_kept_with_the_preferences_and_reset_to_default_leaves_it`) | | Toolbar without group labels by default, one or two columns; All Tools drawer by flyout group with shortcuts, its menu (Reset, Basic, Advanced) and Show section | done | #1751; a preference brings the labels back; `toolbar.show` sets the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls | -| Actions panel: ≡ › Save Actions… saves the current set with all its actions to a file and ≡ › Load Actions… adds a saved set ([Manage a set of actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/manage-a-set-of-actions.html)) | partial | `ui.actionSet.save`, `ui.actionSet.load`; `panels/actions.rs` tests. The file is VectorCraft's own `.vcactions` JSON; the reference app's `.aia` files and Reset, Clear and Replace Actions remain | +| Actions panel: ≡ › Save Actions… saves the current set with all its actions to a file and ≡ › Load Actions… adds a saved set ([Manage a set of actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/manage-a-set-of-actions.html)) | partial | `ui.actionSet.save`, `ui.actionSet.load`; `panels/actions.rs` tests. The file is VectorCraft's own `.vcactions` JSON; Reset, Clear and Replace Actions remain | +| Actions panel: ≡ › Load Actions… also reads action sets saved as `.aia` files ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | partial | `actionSet.read`, `ui.actionSet.load`; `tests/actionset.rs`, `panels/actions.rs` tests, `import_fuzz`. Only steps with a label and no recorded values come in, as menu commands that run with no values and open no dialog, matched by label; the other steps, recorded menu items included, are left out and counted | | Actions panel: ≡ › Action Options… renames the selected action and sets its function key and color ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | partial | `ui.action.options`; `panels/actions.rs` tests (part of #1629). The color shows nowhere until Button Mode exists | | Actions panel: ≡ › Playback Options… plays actions Accelerated, Step by Step or with a pause between steps ([Specify playback speed](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/specify-playback-speed.html)) | done | `ui.action.playbackOptions`; `panels/actions.rs` tests. The pause is a whole number of seconds from 1 to 60 (VectorCraft's own range); Batch… plays at full speed | | Actions panel: a clicked action stays selected for Play Current Selection and Delete Selection | partial | #1853 (part of #1629); editing actions and action shortcuts remain | @@ -384,7 +397,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Symbol libraries: Window › Symbol Libraries (Basic Shapes, User Defined, Other Library…, Save Symbols…) and the Symbols panel's Symbol Libraries Menu button and its menu's Open Symbol Library / Save Symbol Library…; a click on a library symbol adds it to the document's Symbols (one undo step, it becomes the current symbol), Shift/Cmd-clicks select for Add to Symbols, and a symbol dragged onto the canvas is added and placed where it is dropped | done | [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html); `window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary` (dialog `saveSymbolLibrary`), `window.userSymbolLibrary1`…`10`; `panels::tests_symbol_libraries`; only one built-in library (original, generated in code) | | Brush libraries: Window › Brush Libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, User Defined, Other Library…, Save Brushes…) and the Brushes panel's Brush Libraries Menu button and its menu's Open Brush Library / Save Brush Library…; a click on a library brush adds it to the document's Brushes (one undo step, it becomes the current brush, and selected paths take it in the same step), Shift/Cmd-clicks select for Add to Brushes, a brush dragged onto a path is added and applied to it and one dragged onto the Brushes panel is added; thumbnails render with the library's patterns and symbols | done | [public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel overview](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html); `window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary` (dialog `saveBrushLibrary`), `window.userBrushLibrary1`…`10`; `panels::tests_brush_libraries`; four built-in libraries (original, generated in code) | | Symbols panel: New Symbol opens Symbol Options (Name) before making the symbol; Alt-click skips it | partial | #1560 (#1566); Registration 9-point grid in Symbol Options (`symbol.new` / `symbol.place`); Export Type (Graphic / Movie Clip) and Enable Guides for 9-Slice Scaling (scaled instances keep their corners; in symbol editing mode the guides are drawn over the art and the Selection tool drags them); Symbol Type (Dynamic Symbol / Static Symbol, new symbols dynamic; #4938) | -| Dynamic symbols: an instance changes the fill, stroke, opacity and blend of objects in its symbol's art and stays linked; Reset in the Properties panel's Quick Actions and the Symbols panel menu (Reset Symbol Instance) | partial | #4938; `symbol.instance.setAppearance` (by child-index path) and `symbol.instance.reset`; redefining keeps the changes, Break Link bakes them; on-canvas Direct Selection of a part inside an instance (with the Fill/Stroke controls then setting its change) remains | +| Dynamic symbols: an instance changes the fill, stroke, opacity and blend of objects in its symbol's art and stays linked; Reset in the Properties panel's Quick Actions and the Symbols panel menu (Reset Symbol Instance) | partial | #4938; `symbol.instance.setAppearance` (by child-index path) and `symbol.instance.reset`; redefining keeps the changes, Break Link bakes them; Direct Selection of a path, compound path or type object inside an instance, with the Fill/Stroke controls setting its solid fill or stroke, is on canvas; on-canvas editing beyond a solid fill and stroke, and `.ai` read and write, remain | | Pathfinder Trim, Merge and Crop remove strokes; Merge joins same-fill objects whatever their strokes | done | #1570 (#1572) | | Pathfinder panel on one group selected alone; a greyed-out button says what it needs | partial | #1450; the group's members are combined; one plain path selected alone stays disabled (the public documentation doesn't say what the operations do to a single path) | | Pathfinder Divide: an open cutting line splits a filled path | done | #1307; straight, crossed, bent and curved cutters, holes and frontmost appearance regression-tested; native automation: independent piece movement, one Undo/Redo, saved-document round trip and window capture; existing bounded-map fallback remains | @@ -402,6 +415,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | SVG Interactivity, Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) | | Workspaces (save, reset, switch); each remembers the layout changes made in it across switches and restarts until Reset <name> | done | `UiState::workspace_changes` | | Opening a file: after 0.4 s a progress window with the file name, a moving bar (reading counts no steps), how many more files wait, Cancel (Esc: the file doesn't open) and Continue in Background; never over another dialog or progress window | done | #5930; `opening::show`, `file.cancelOpen`, `file.openInBackground`. Save, Place and Close still show only the status bar's spinner (Place and Save run off the UI thread; Close is immediate) | +| Window › Help Bar shows or hides the tool-help strip apart from the status bar; kept with the preferences and in saved workspaces; Presentation Mode hides both bars | done | `window.helpBar`; `help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar` | | Background Export: a progress dialog with the file name, the artboard count and a bar (busy for one artboard and for formats made in one go), Cancel (Esc) and Continue in Background; it opens after 0.4 s and never over another dialog. A cancelled export writes no file | done | #2564; on the shared `vectorcraft_doc::cancel` token (#2546), so a cancelled render keeps no half-drawn pattern tile or effect raster. The bar moves between artboards, not within one. Save As, Export for Screens and Export Selection don't show it yet | | Swatch, graphic style and symbol library panels: each library opens in a panel of its own, several at once, that floats, groups, docks and closes like any panel; opening an open library brings its panel forward; Previous / Next Library change the library in place; the panel menu's Persistent reopens the panel at startup with its view and find field (a library loaded from a file is read again; when its file is missing the panel reopens showing the file's path with Locate Library…, and the status bar names the file) | done | #2603; panel ids `library:<kind>:<id>`, `ui.libraryPanel {panel, library?, view?, showFind?, persistent?}`, `ui.libraryPanel.locate {panel, path?}`; the library panels have no sort option, as before | | DXF export/import preserves intended curve closure | done | #1299; R13–2018 closed SPLINE bit set/read; an open curve ending at its start stays open; knots and control points unchanged; native CIRCLE/ARC export and CNC preset remain pending | @@ -434,6 +448,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Color Effects add-on (VectorCraft addition): Object › Plug-ins › Color Effects › Find Nearest Swatch… picks a Library (Document Swatches, a built-in library, or a library or colour book the user saved or loaded) and Fill and Stroke, lists each colour of the selection with its three closest swatches (chip, name, ΔE 2000), and Apply gives each colour its nearest swatch (global and spot swatches linked; a library's swatches added to the document first), one undo step | done | #4923; `addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, `crates/engine/src/cmd/addons/color/nearest.rs`, `crates/ui-egui/src/addons/color/nearest.rs` and their tests; not a feature of the reference app, so it is not scored | | Selection Tools add-on (VectorCraft addition): Object › Plug-ins › Selection Tools › Select by Artboards… lists every artboard (number, name, width × height in the document's units), filters them by name, chooses several (click, Shift-click for a range, Cmd/Ctrl-click to toggle; Select All / None act on the rows shown; a count of those chosen), fits a row's artboard in the window on double-click and makes it active, and on OK selects the objects overlapping, fully inside or fully outside the chosen artboards (Overlapping is All on Active Artboard's test) | done | #5525, #5548, #5549; `addon.select.byArtboards`, `crates/engine/src/cmd/addons/select.rs`, `crates/ui-egui/src/addons/select.rs` and their tests; not a feature of the reference app | | Selection Tools add-on (VectorCraft addition): Select Linked, Select Embedded, Select Missing and Select Modified in Object › Plug-ins › Selection Tools and the Links panel menu (off when no image has that status; Shift adds to the selection) select the images and placed documents with that link status, leaving out hidden, locked, template-layer and outside-isolation ones, which the status bar counts (e.g. "Selected 9 missing images; 3 more are hidden or locked"); Select by Link Status… lists them with name, status and format, searches by name, filters by status, picks with checkboxes and All / None, and selects with Zoom to Selection on by default | done | #5526; `addon.select.byLinkStatus`, `crates/engine/src/cmd/addons/select.rs`, `crates/ui-egui/src/addons/select/link_status.rs` and their tests; not a feature of the reference app | +| Style panels: a double-click on a user character style's row opens its Character Style Options, and on a paragraph style's row its Paragraph Style Options (rename), and the Character Style Options dialog keeps a fixed width, its sections scrolling when the window is shorter so OK and Cancel stay on screen | done | `ui.charStyleOptions`, `ui.paramDialog` (`paraStyle.rename`); `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options`, `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list` | ## Look and feel @@ -478,6 +493,10 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. | +| 2026-11-09 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | +| 2026-11-07 | minor | Pen and Curvature tool with Cmd/Ctrl held: Edit › Copy and Cut take the anchors and segments the lent Direct Selection picked, as with Direct Selection chosen, not the whole path; with the Pen's own last anchor selected Copy still takes the whole path (#6123); scores and estimates unchanged | +| 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected, a Shift-click on a corner widget picks that corner alone instead of deselecting it (#5188); adding a hidden corner's widget by Shift-click stays open (#1232); scores and estimates unchanged | | 2026-11-07 | minor | Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged. | | 2026-11-06 | minor | Keyboard shortcuts: Cmd/Ctrl+Alt+. and Cmd/Ctrl+Alt+, (Stroke Weight Up and Down) step each selected stroke's weight, or the next object drawn's (#5797); scores and estimates unchanged | | 2026-11-06 | minor | Properties: Quick Actions has Repeat Options for one selected radial, grid or mirror repeat, opening Object › Repeat › Options with its values (#5181); scores and estimates unchanged | @@ -595,9 +614,6 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-22 | minor | Gradient panel: the Method menu, Classic or Perceptual (Illustrator 30.0); scores and estimates unchanged. | | 2026-10-22 | minor | Gradient panel: the Dither box (Illustrator 30.0); scores and estimates unchanged. | | 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. | -| 2026-10-22 | minor | Artboard Options honors Scale Artwork with Artboard for preset, orientation and typed-size changes; both Fits keep artwork stationary; scores and estimates unchanged. | -| 2026-10-22 | minor | Gradient panel: the Method menu, Classic or Perceptual (Illustrator 30.0); scores and estimates unchanged. | -| 2026-10-22 | minor | Gradient panel: the Dither box (Illustrator 30.0); scores and estimates unchanged. | | 2026-10-21 | minor | Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations. Scores and estimates unchanged. | | 2026-10-21 | minor | Workspaces keep the layout changes made in them when switching away and back, and across restarts; Reset <name> returns to the saved layout; score unchanged (estimated) | | 2026-10-21 | minor | Panels dragged by the mouse join a dock group as tabs, split it top/bottom or become icons in the icon column; dock tabs close from their context menu; score unchanged (estimated) | diff --git a/packaging/web/README.md b/packaging/web/README.md index 38a8d6209..cac402c6b 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -50,6 +50,23 @@ location /vectorcraft/ { Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/. +## Browser requirements + +The wasm is built with WebAssembly SIMD (`-C target-feature=+simd128`, `.cargo/config.toml`, +#2786), so it needs a browser that has it: + +| Browser | First version | +|---|---| +| Chrome, Edge | 91 | +| Firefox | 89 | +| Safari (macOS and iOS/iPadOS) | 16.4 | + +Source: caniuse `wasm-simd` and the WebAssembly feature table (webassembly.org/features). An older +browser can't compile the module. There is no fallback build and no message: the page stays on +"Loading VectorCraft..." and the console shows a WebAssembly compile error. The app also needs +WebGPU or WebGL2 (see "Renderer selection" below). If you must serve older browsers, put a +version check in front of the app on your own page. + ## Fonts folder Builds made with craft-fonts (`CRAFT_FONTS_DIR`, all releases) have a `fonts/` folder beside the diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 334910732..260b10387 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -43,7 +43,10 @@ commands: pack PNGs into a Windows .ico (used by packaging/icons.sh) 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) and parse its ES5 subset with vectorcraft-js, printing the counts + verified), parse its ES5 subset with vectorcraft-js and run the ES5 language + and built-in tests its interpreter supports and the String, Number, RegExp + and Date built-ins tests, 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) "; @@ -338,7 +341,10 @@ Tests that use a corpus skip cleanly when it is absent. corpus/affinity/ public Affinity documents (CC0/MIT/Apache-2.0) at pinned commits, sha256 verified: oracles for the Affinity reader. Fetch: cargo xtask corpus --affinity corpus/test262/ the ECMAScript conformance suite (BSD-3-Clause) at a pinned commit, sha256 - verified: the JavaScript parser's ES5 subset. Fetch and run: cargo xtask test262 + 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() ); diff --git a/xtask/src/test262.rs b/xtask/src/test262.rs index ab390538b..dfed1d8ae 100644 --- a/xtask/src/test262.rs +++ b/xtask/src/test262.rs @@ -1,16 +1,35 @@ //! `cargo xtask test262`: the official ECMAScript conformance suite (https://github.com/tc39/test262, //! 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` test, which parses the ES5 parse subset and prints its counts. +//! runs `vectorcraft-js`'s `test262` tests, which parse the ES5 parse subset, run the ES5 +//! `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, run the `test/built-ins` +//! tests for `String`, `Number`, `RegExp` and `Date`, 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"; @@ -24,11 +43,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<PathBuf, String> { + let base = root().join("corpus").join("json2").join(JSON2_COMMIT); + let file = base.join("json2.js"); + let matches = |p: &Path| -> Result<bool, String> { + 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<std::path::PathBuf, String> { +fn fetch() -> Result<PathBuf, String> { let base = root().join("corpus").join("test262"); let dir = base.join(format!("test262-{COMMIT}")); let marker = dir.join(".vectorcraft-sha256");