From e27f3b9968fadbb6a58cb28a7821da1abd4fa1bd Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 23:26:30 -0400 Subject: [PATCH 01/98] Flatpak bundle: skip the debuginfo pass manifest-wide (runner has no eu-strip) Co-Authored-By: Claude Opus 5.5 --- .../linux/flatpak/ai.storyteller.vectorcraft.bundle.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml index f9cee58d64..840abbc53f 100644 --- a/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml @@ -48,6 +48,11 @@ finish-args: # this the font list holds none of the activated families, while native apps see them. - --filesystem=~/.fonts:ro # Deliberately not granted: home/host filesystem access beyond the read-only folders above. +# The release runner's flatpak-builder has no eu-strip (elfutils), which its debuginfo pass runs +# on cups-client's unstripped tools, in whichever module comes first after they're installed. The +# app's own binaries come from the release tarball, already stripped. +build-options: + no-debuginfo: true modules: # CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions. # Linked statically against this release's libcups, so a runtime update can't break them; From bcebabe3f8c23a376ac68e9e6726590af278f7bb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 23:26:38 -0400 Subject: [PATCH 02/98] Release 0.19.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 56 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f0f4dfd948..7703d3e33b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6037,7 +6037,7 @@ dependencies = [ [[package]] name = "vectorcraft" -version = "0.18.0" +version = "0.19.0" dependencies = [ "arboard", "block2 0.6.2", @@ -6081,7 +6081,7 @@ dependencies = [ [[package]] name = "vectorcraft-affinity" -version = "0.18.0" +version = "0.19.0" dependencies = [ "crc32fast", "flate2", @@ -6092,7 +6092,7 @@ dependencies = [ [[package]] name = "vectorcraft-brush" -version = "0.18.0" +version = "0.19.0" dependencies = [ "kurbo 0.13.1", "serde", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "vectorcraft-cad" -version = "0.18.0" +version = "0.19.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6120,7 +6120,7 @@ dependencies = [ [[package]] name = "vectorcraft-cli" -version = "0.18.0" +version = "0.19.0" dependencies = [ "log", "objc2-core-foundation", @@ -6142,7 +6142,7 @@ dependencies = [ [[package]] name = "vectorcraft-color" -version = "0.18.0" +version = "0.19.0" dependencies = [ "kurbo 0.13.1", "moxcms", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "vectorcraft-doc" -version = "0.18.0" +version = "0.19.0" dependencies = [ "chrono", "hayro-ccitt", @@ -6173,7 +6173,7 @@ dependencies = [ [[package]] name = "vectorcraft-effects" -version = "0.18.0" +version = "0.19.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6189,7 +6189,7 @@ dependencies = [ [[package]] name = "vectorcraft-engine" -version = "0.18.0" +version = "0.19.0" dependencies = [ "craft-docpath", "crc32fast", @@ -6228,7 +6228,7 @@ dependencies = [ [[package]] name = "vectorcraft-eps" -version = "0.18.0" +version = "0.19.0" dependencies = [ "fax", "flate2", @@ -6252,7 +6252,7 @@ dependencies = [ [[package]] name = "vectorcraft-format" -version = "0.18.0" +version = "0.19.0" dependencies = [ "flate2", "proptest", @@ -6267,7 +6267,7 @@ dependencies = [ [[package]] name = "vectorcraft-geom" -version = "0.18.0" +version = "0.19.0" dependencies = [ "kurbo 0.13.1", "proptest", @@ -6279,7 +6279,7 @@ dependencies = [ [[package]] name = "vectorcraft-mcp" -version = "0.18.0" +version = "0.19.0" dependencies = [ "image", "log", @@ -6298,7 +6298,7 @@ dependencies = [ [[package]] name = "vectorcraft-metafile" -version = "0.18.0" +version = "0.19.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6312,7 +6312,7 @@ dependencies = [ [[package]] name = "vectorcraft-pathops" -version = "0.18.0" +version = "0.19.0" dependencies = [ "kurbo 0.13.1", "linesweeper", @@ -6324,7 +6324,7 @@ dependencies = [ [[package]] name = "vectorcraft-pdf" -version = "0.18.0" +version = "0.19.0" dependencies = [ "aes", "chrono", @@ -6356,7 +6356,7 @@ dependencies = [ [[package]] name = "vectorcraft-plugins" -version = "0.18.0" +version = "0.19.0" dependencies = [ "serde", "serde_json", @@ -6370,7 +6370,7 @@ dependencies = [ [[package]] name = "vectorcraft-render" -version = "0.18.0" +version = "0.19.0" dependencies = [ "flate2", "gif", @@ -6399,14 +6399,14 @@ dependencies = [ [[package]] name = "vectorcraft-scriptcheck" -version = "0.18.0" +version = "0.19.0" dependencies = [ "proptest", ] [[package]] name = "vectorcraft-svg" -version = "0.18.0" +version = "0.19.0" dependencies = [ "craft-docpath", "flate2", @@ -6431,7 +6431,7 @@ dependencies = [ [[package]] name = "vectorcraft-testkit" -version = "0.18.0" +version = "0.19.0" dependencies = [ "fax", "image", @@ -6449,7 +6449,7 @@ dependencies = [ [[package]] name = "vectorcraft-text" -version = "0.18.0" +version = "0.19.0" dependencies = [ "harfrust 0.13.3", "icu_segmenter", @@ -6470,7 +6470,7 @@ dependencies = [ [[package]] name = "vectorcraft-three-d" -version = "0.18.0" +version = "0.19.0" dependencies = [ "vectorcraft-geom", "vectorcraft-pathops", @@ -6478,7 +6478,7 @@ dependencies = [ [[package]] name = "vectorcraft-tools" -version = "0.18.0" +version = "0.19.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6494,7 +6494,7 @@ dependencies = [ [[package]] name = "vectorcraft-trace" -version = "0.18.0" +version = "0.19.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "vectorcraft-ui-egui" -version = "0.18.0" +version = "0.19.0" dependencies = [ "bytemuck", "craft-keymap", @@ -6544,7 +6544,7 @@ dependencies = [ [[package]] name = "vectorcraft-web" -version = "0.18.0" +version = "0.19.0" dependencies = [ "eframe", "egui", @@ -7675,7 +7675,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.18.0" +version = "0.19.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 4b14fbb371..457a284d23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"] [workspace.package] -version = "0.18.0" +version = "0.19.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 8ae1f7dae6e335cb3a49835925c67ce64fd97208 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 04:17:23 +0000 Subject: [PATCH 03/98] docs: tidy agent, contributor and asset docs --- AGENTS.md | 13 +++++-------- ASSETS.md | 2 +- CLAUDE.md | 6 +++--- ROADMAP.md | 2 +- assets/app-icon/README.md | 4 +--- contributors/people.toml | 7 ++----- crates/text/src/craft_fonts.rs | 3 +-- crates/ui-egui/build.rs | 7 +++---- crates/ui-egui/src/credits.rs | 2 +- docs/contributors.md | 25 ++++--------------------- docs/development.md | 3 +-- docs/releasing.md | 2 +- docs/roadmap.md | 2 +- 13 files changed, 25 insertions(+), 53 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1677f01c52..0d33612d5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - Creative Commons with redistribution allowed. - **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`. - `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset. - - **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts. + - **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo**. Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts. - Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft. - Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it. - Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets. @@ -46,7 +46,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`. - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). - **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). -- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. +- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`). ## Running and looking at the app @@ -59,7 +59,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`). ## Roadmap and progress docs -The progress docs follow craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md): +The progress docs: - [`ROADMAP.md`](ROADMAP.md): stage, headline numbers, dimensions, features, languages, upcoming, progress log. - [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative parity assessment (area table, methodology, calibration, evidence of what ships). - [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list. [`docs/roadmap.md`](docs/roadmap.md): milestones and current focus. @@ -72,20 +72,17 @@ When a task lands, update in the same PR: the area row in `docs/target-app-parit ## Contributor credits (About window) - About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json` - (commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for + (commit stats; never hand-edit) and `contributors/people.toml` (names people chose for themselves). See `docs/contributors.md`. - **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once whether they want to be credited by more than their username: a real name, a display name, and/or their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry - (copy the template at the top of the file, or run - `python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`) + (copy the template at the top of the file) and include it in their PR, committed as them. If no, change nothing: they are credited as `@username` anyway. - Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or GitHub profiles). Never hand-edit `contributors.json`. -- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also - re-verifies who wrote each `people.toml` entry; `--check` only verifies). ## CI workflows - Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. diff --git a/ASSETS.md b/ASSETS.md index 26d4630911..5bebefae63 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -6,7 +6,7 @@ Every non-code asset in this repository (icons, images, fonts, example art, colo Generated-in-code art is original and has no file to list. This covers the default swatches, brushes, symbols, patterns, graphic styles, image-trace presets and the vector tool cursors (`crates/ui-egui/src/cursors.rs`). -**Font files are never committed here.** Fonts shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). The small Latin UI/document fonts below predate that rule and stay. +**Font files are never committed here.** Fonts shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts). The small Latin UI/document fonts below predate that rule and stay. **Optional build input: craft-fonts.** Builds made with `CRAFT_FONTS_DIR=` (all official releases) embed the Japanese fonts listed in its [`ATTRIBUTION.md`](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md): BIZ UDPGothic Regular and Bold, Shippori Mincho Regular and BIZ UDMincho Regular (web builds: BIZ UDPGothic Regular only), all under the SIL Open Font License 1.1. They are not files in this repository; release packages carry each one's `OFL-.txt`. Builds without `CRAFT_FONTS_DIR` embed none of them. diff --git a/CLAUDE.md b/CLAUDE.md index 2b6ffcc436..17783d9a1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - Creative Commons with redistribution allowed. - **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`. - `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset. - - **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts. + - **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo**. Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts. - Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft. - Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it. - Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets. @@ -47,7 +47,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`. - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). - **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). -- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. +- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`). ## Running and looking at the app @@ -60,7 +60,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`). ## Roadmap and progress docs -The progress docs follow craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md): +The progress docs: - [`ROADMAP.md`](ROADMAP.md): stage, headline numbers, dimensions, features, languages, upcoming, progress log. - [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative parity assessment (area table, methodology, calibration, evidence of what ships). - [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list. [`docs/roadmap.md`](docs/roadmap.md): milestones and current focus. diff --git a/ROADMAP.md b/ROADMAP.md index c8fa604074..04396fffe1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -186,7 +186,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undo step (#1331). EPS import no longer hangs on shading functions fanned out through shared arrays (#1330), looped huge arcs, fine dashes and rectangle arrays (#1329), or very long dash patterns (#1332). Plug-in whole-number fields keep values past 2^53 exactly as typed (#1327). Large documents use far less memory: decoded images are shared and capped, undo history is bounded by memory, native files open in the background and save as a stream, and Data Recovery writes images once (#1245). Opacity mask commands (#1336), Simplify and Add Anchor Points (#1337) add no undo step when they change nothing. EPS files with thousands of tiling patterns, spot inks or clipped shadings open several times faster with the same result (#1341). A `.ai` or PDF saved from a CMYK document with transparency no longer opens blank in applications that read soft masks by luminosity (#1359, part of #1286). Gradient-filled shapes in a PDF or `.ai` open as one path with the gradient instead of a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on their outlines (#1351). A `.ai` saved with a bleed opens on its artboard with the bleed round it as the document's bleed, from its layers, and Crop To › Trim does the same for a PDF (#1349); a PDF saved with its editing data opens from it, with its layers, groups and art past the page, as a `.ai` does (#1345). Art moved into a hidden or locked layer (row drag, Merge Selected, Flatten Artwork, Collect in New Layer) or hidden by a data set is deselected (#1355), and hiding or locking a layer with a large selection deselects its objects in linear time (200,000 objects: 3.9 s to 24 ms) (#1353). Objects moved whole together with direct-selected anchors follow Preferences › Transform Pattern Tiles (#1354). Preferences › Appearance of Black › On Screen works: Display All Blacks as Rich Black shows 100K black as RGB black on the canvas (#1350). DXF: a closed control-point SPLINE opens as a closed path (#1318). Numeric fields: dragging over part of a value in an unfocused field selects that part instead of the whole value (#1339). Pathfinder Divide: an open line or curve across a filled shape cuts it into separate pieces (#1335); Unite, Minus Front, Minus Back, Intersect and Exclude results keep the source object's name, so exported SVG keeps its id (#1308). Object › Align follows the Align To choice (one object aligns to the artboard), and the choice is kept across restarts (#1347). Artboard tool: a click on an object creates an artboard fitted to it (#1252). Rulers: drag the ruler corner to move the zero point, double-click it to reset; the X/Y fields measure from it (#1250). Live Corners: with Direct Selection, a click on a corner widget selects that corner, so the next drag rounds it alone (#1239). A click with the Polar Grid tool opens a dialog for its size and divider counts, as the Rectangular Grid tool does (#1192). A `.ai` whose editing data holds an image on hidden lines is no longer misread (#1364, part of #1264). PDF export leaves out art placed beyond 1,000,000,000 pt instead of aborting debug and test builds (#1360). Affinity import: rectangles keep per-corner radii and straight (cut) corners (#1369, part of #666). Tools panel: the tool buttons are centred inside the panel border, in one and two columns (#1363). Spanish: 371 more interface strings translated (presets, shortcut editor, print and PDF options, Save for Web and more) (#1371). The Properties panel no longer widens and cuts off its labels when an Image Trace object is selected (#1374, part of #1358). Art drawn flush with an artboard (a background a rounding error outside it) moves, copies and scales with the artboard again (#1375, from #1295). A character no installed font had is looked for again after the fonts are rescanned, so installing a font fixes it without a restart (#1376, from #992). A PDF saved with its editing data and a bleed opens on its artboard with the bleed round it, as a `.ai` does (#1379, follow-up to #1349 and #1345). Dialogs opened from a menu focus their first field every time they open, not only the first time, so typing, Tab and Enter work (#1382, fixes #1367); a dialog whose first field is greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the keyboard focus to that field once it is enabled (#1395). In a `.ai` opened from its editing data, a gradient with stops in the middle draws across its whole 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) (follow-ups to #1264). `--control` without a valid port (or a bad `VECTORCRAFT_CONTROL_PORT`) stops the desktop app with an error instead of starting without a control server, and `--control=` works too (#1383). Documents with many symbol instances or inline graphics redraw faster and use less memory: the art placed for each instance is kept between frames (#1386). PDF and `.ai` import and placed PDFs keep overprint (`/OP`, `/op`), so overprinting spot colours still overprint after export (#1413, fixes #1388). A `.ai` whose PDF part draws only text opens from that part when its editing data can't be read, instead of being refused (#1414, fixes #1387). In a `.ai` opened from its editing data, a compound path takes the paint of its last path, so its gradient no longer comes in black, and paths grouped inside a compound path stay its paths (#1426, fixes #1410). With anchors selected with Direct Selection, the Scale, Rotate, Reflect and Shear tools, their dialogs and Move transform only those anchors (#1427, fixes #1408). The Move, Rotate, Scale, Reflect and Shear dialogs have a live Preview, and the arrow keys step their angles and percentages by 1 (Shift: 10) (#1428, fixes #1412). A stroke wider than its closed shape (a small ellipse or rectangle with a heavy stroke) paints solid on the canvas, as in exports, instead of showing a hole (#1409, fixes #1396). The bounding box hides while a corner radius is dragged with the Selection tool (#1402, fixes #1229). Pasting a picture works when the clipboard also holds a blank text item beside it (#1404). `document.inspect` reports a compound path's fill, stroke and weight and every node's blend mode when it isn't Normal (#1397, fixes #1389); `object.isolate` with an id that doesn't exist says there is no such object (#1415); the MCP guide covers Spacing Guides and four more preferences, all resources, and the symbol, brush, variable and Image Trace commands (#1418). PDF and `.ai` import keep a stroke opaque and unmasked when the fill under it was faded or masked by its group, and a `.ai` read as its PDF part keeps its RGB numbers (#1434). SVG export writes knockout groups of opaque art without masks (smaller files, no edge seams), and `vectorcraft-cli convert` takes `--decimals` (#1435). Delete, Cut, Group, Ungroup, Make and Release Compound Path, Make Clipping Mask, Send to Current Layer, Duplicate and the copying transforms of thousands of objects take a fraction of a second instead of seconds (#1430, part of #1429), and an Alt-drag with the selection and transform tools copies the selection once per drag instead of on every move (#1464, part of #1429). An Affinity text frame too short for its first line (a 64 pt title in a 54 pt frame) is made taller on import, with a note, instead of showing nothing (#1449, fixes #1423). Fit Headline no longer pushes the tracking to its ceiling and loses glyphs when the frame holds one line (#1445, fixes #1444). Text frames, wrap shapes and type paths with coordinates beyond a billion points (from damaged files) hold no text instead of hanging the layout (#1451). Czech: 128 more strings translated (#1461). Plug-ins: a numeric parameter's default stays inside its declared range (#1463). Split Into Grid has the documented options: number, height or width, gutter and total for rows and columns, Add Guides and Preview; several selected paths become one grid painted as the topmost one (#1488, fixes #1462). Grayscale TIFFs with an alpha channel or other extra channels can be placed, relinked and opened, the alpha as the image's transparency (#1489, part of #1466). Selected text in numeric and dialog fields is drawn in white or black instead of blue on the blue highlight (#1490, part of #1465). The Pathfinder panel works on a group selected alone (its members are combined), and a greyed-out button says what it needs (#1493, for #1450). Transform Objects joins Transform Patterns in the Move, Rotate, Scale, Reflect and Shear dialogs and in Transform Each (off: only the pattern tiles transform), and Reflect takes an angle for its axis (#1494, fixes #1448 and #1460). A window larger than 8192 px (several monitors, a display-scale change) keeps its surface instead of closing the app (#1495, part of #1459). Commands run through MCP, the control channel and the CLI refuse a parameter key they don't take, naming it and the nearest accepted key (#1496, fixes #1442). A focused number field follows its value when a slider or preview changes it, so the next key no longer puts the old value back (#1497, fixes #1457). Effect › 3D and Materials: outlined words and other shapes of many small curves take Rotate, Extrude & Bevel and Inflate (curves are sampled by a tolerance; Rotate takes up to 65,536 outline points; #1481), Extrude & Bevel's Twist runs 0° to 360°, as the public panel reference gives it (#1524), and a twisted extrusion keeps its walls (from about 45° they were dropped; #1525). Text commands and the MCP `add_text` tool say when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). A tab stop in the Tabs panel drags from where the press landed, including one just added (#1487, fixes #1458). `graph.setData`'s CSV keeps each value exactly instead of rounding it to two decimals, so applying the returned CSV changes nothing (#1485). In Outline view a template layer keeps drawing in Preview, dimmed, so a picture placed to be traced stays visible (#1505, fixes #1486). Sliders in Save for Web, the raster export options, Preferences and the opacity popup show their rail, filled up to the handle (#1506, fixes #1483). Direct Selection picks the anchor of the selected object where anchors of overlapping objects coincide, also under another object (#1509, fixes #1508). Print-size images (past 512 MB decoded, up to 2 GB) draw in the canvas and in raster exports and can be trimmed with Crop Image; an image whose side is past 65,535 px is shrunk to fit, a raster export says when an image could not be decoded, and Crop Image's trim leaves CMYK images alone (#1517). On Windows, `vectorcraft-cli perf` reports the cores busy and, with `--deep`, its working set, and warns on a busy machine as on macOS and Linux (#1502). French: 423 missing strings added and 188 entries reworded to the feature names French users know (#1511). Hungarian: one message uses the informal address like the rest of the interface (#1474). | | 2026-10-12 | Georgian interface (`ka`, ქართული): the whole catalog, plurals and messages, tested complete; Georgian letters come from an installed font (SF Georgian, Sylfaen, Noto Sans Georgian, DejaVu Sans) from the first frame; Type › Change Case › Title Case and Sentence case leave Georgian lower case instead of turning it into Mtavruli. | | 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and Distribute Spacing keep the key object still also when it is inside a selected group or compound path (#1074). A `.ai` whose layers can't be read yet still opens with its artboards where they were (in a grid, say), not in a row (#1068). Layer Options (`ui.layerOptions`) refuses a malformed or unknown row id instead of skipping it or opening on the highlighted rows (#1077). Shaper scribbles given a list of object ids refuse a malformed or unknown id instead of skipping it and editing the shapes that remain (#1078). Delete Width Point (`stroke.widthPoint.remove`) refuses a list of indices with a malformed entry instead of deleting the points that remain, and no longer wraps a huge index round on the web (#1079). Preferences' checkboxes have an outline when the pointer isn't over them, in every interface brightness, as the app's other dialogs' checkboxes do (#1080). The last plain checkboxes (Properties panel snapping and Scale Strokes & Effects, Revolve, Save options, Export for Screens, Variables, plug-in forms) keep a visible box at rest in every theme too (#885). Setting or copying a width point with a malformed index is refused instead of adding a new point or wrapping a huge index round (follow-up to #1079). An Illustrator EPS opened through its layers keeps its own page size and the red outline of text stacked over a dark one, as 0.8.0 did (#1102). The macOS DMG is built so the app in it passes `codesign --verify --strict --deep`, and the release checks that it does (#1085). Swatch and graphic style libraries, Libraries panel files, presets and plug-ins dropped on the window open as File › Open opens them instead of being placed on the canvas (#1083). A key object needs another selected object outside it: a change that leaves one object selected lets the key go, and one selected object aligns to the artboard whatever Align To says (#1088). Align to Artboard aligns to the active artboard, and actions play their Align to Artboard, All on Active Artboard and Paste in Place, in Front and in Back steps on the artboard active when they play (#1105). Align refuses a call with neither direction, and Align, Distribute, Reflect and Shear refuse a direction, axis, target or bounds they don't take instead of falling back to a default (#1106). `object.setProps` deselects the objects it hides or locks, the key object included, as the Layers panel's eye and lock do (#1107). Align, Distribute and Distribute Spacing move pattern tiles with the art when General › Transform Pattern Tiles is on (#1108). Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included (#1109). Save Swatch Library's `count` is the number of swatches the file holds: `.gpl` and `.ase` hold solid colors only, and CSS leaves freeform gradients out (#1110). In a rotated view (Rotate View), an artboard's name stays at its own top-left corner (#1095). Ctrl+Shift+L, C, R, J and F align or justify the paragraphs of the selected type, or those the Type tool's selection touches (#1111). On macOS, file dialogs are sheets on the document window, so resizing one no longer crashes the app; while one is open the menus, Quit and Close wait, and its answer goes to the document that asked (#867). Shape Builder merges regions that crossing lines cut along the lines' own edges, so a strip merged across a grid of lines is one outline with no seams left inside (#1081). Proportional Metrics also works in vertical type: upright glyphs take the font's proportional heights (`vpal`), and Line-end Punctuation Half Width takes nothing more off the punctuation it re-spaced (#1082). Plug-in whole-number parameters keep their exact value however large, and stay in the range the manifest gives (#1091). PDF and `.ai` import keeps type spaced glyph by glyph (character tightening, optical kerning) with each glyph in its place, giving each character its own tracking when the file's font is installed, and CJK punctuation closed up by its proportional width continues its line (#1101). Direct Selection: pressing an anchor of a path selected whole keeps all its anchors selected, so the drag moves them all; Alt-dragging by an anchor copies the whole path, held by that anchor, which snaps onto other anchors and onto the original the copy leaves behind; Alt pressed or released during the drag turns the copy on and off, as with the Selection tool, whose copies snap to their original too, and the undo step is named Copy or Move for what the drag did (#1104). The toolbar's Fill/Stroke proxy and Color, Gradient and None buttons grow in two columns, where the three drawing modes sit side by side (#1129). An image copied in a browser that puts its `` markup on the clipboard as text pastes as the picture, not the markup (#1089). `file.place` of an SVG with `crop: "crop"` frames and clips it by its viewBox, so art drawn on a shared canvas keeps its place and scale (#788). While a star is dragged, Ctrl/Cmd keeps its inner radius as the outer one grows and Alt keeps its shoulders straight (#812). Edit Toolbar opens the All Tools drawer beside the toolbar, dims the tools already in it, and adds, removes and moves tools by drag and drop while it is open (#1018). The Selection tool's arrow is black and Direct Selection's white in every theme, as users know them: in the dark themes they looked swapped. The font menu keeps its highlight on the same font when the list changes under it (a family's styles listed above it, fonts that finish loading, a favourite unstarred), and ends the live preview when that font leaves the list (#1116). The Windows installer has a Shortcuts page with a ticked "Create a desktop shortcut" box, and a silent install adds the shortcut unless `INSTALLDESKTOPSHORTCUT=""` is passed (#1128). Image Trace's Noise merges specks until none under its size is left beside another colour, so a higher Noise never gives more paths (#1130). `cargo clippy -D warnings` passes for the wasm32 web build (#1132). Gradient stops with opacity blend colour and opacity apart on the canvas, as Illustrator and the PDF and SVG exports do: a transparent stop's colour still tints the fade (#1142). An SVG mask whose art is all hidden imports as an empty opacity mask, so its object stays hidden as browsers draw it (#1143). Shift-click with the Artboard tool, or in the Artboards panel, selects several artboards, which then move, duplicate (Alt-drag) and delete together (#1139). A shape far beyond any page (from a malformed file) is left undrawn instead of making the renderer abort the app. Live Corners round corners where curved segments meet too, trimming each curve to what the cut leaves of it (#1144). Effect › Distort & Transform › Zig Zag's dialog has sliders for Size and Ridges per segment, and Relative / Absolute and Smooth / Corner as radio buttons (#1133). Object › Path › Offset Path opens on the offset, joins and miter limit it was last applied with (#1152). A marquee selects only the objects whose paths it touches: one drawn over a background, in Preview or Outline, no longer selects the background too (#1103). On Linux, Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland on a Wayland session, where files dropped from a file manager arrive (#1151). A selected gradient mesh shows its mesh lines and points with the Selection and Direct Selection tools too, not only with the Mesh tool (#1111). A blend's spine bends: the Anchor Point tool drags any of its segments into a curve, straight ones too, and Direct Selection reshapes its curved segments (`object.blend.spine.reshapeSegment`, #1111). Alt-click on a hidden layer's eye shows that layer as it hides the others, and on a locked layer's lock unlocks it as it locks the others: the layer clicked is the one left in view (#1158). Object › Compound Path › Release splits a path of several subpaths (an SVG `d` with several `M`s, a path drawn in one go over MCP) into one path per subpath, with its paint and stacking order, as it does a compound path (#1163). MCP `render_preview` takes an `artboard` index, so an agent can look at every page of a multi-artboard document (#1163). The MCP drawing tools say that omitted paint follows the new-art appearance (a white fill and 1 pt black stroke at first), and `export` says which formats take `outlineText` (#1148). The desktop app refuses a malformed `--automation-read-root` / `--automation-write-root` (no folder, an empty one, another option or the flag twice) before it starts, instead of dropping the root its environment variable set and leaving the control channel unconfined (#1157). PDF export warns, once per object, when the 64 million pixel cap renders its raster effects below the document's raster effects resolution, with the requested and the achieved ppi (#1147). MCP `command_batch` with `atomic: true` makes the whole batch one undo step, named by `label` (`edit.undoGroup.begin`/`end`, #1163). `file.list {path}` lists a folder's files and folders for agents, held to the automation read root (#1163). Thai typed in a font without Thai glyphs is set in an installed font made for Thai (Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai…) instead of whichever face with Thai glyphs loaded first, and Lao, Khmer and Myanmar likewise. With the Type tool, the Left and Right arrows and Delete step over whole clusters (a Thai consonant with its vowel and tone marks, a letter with a combining accent, an emoji sequence), so the caret never lands inside one; Backspace still takes back one character at a time, a mistyped tone mark say. Thai, Lao, Khmer and Myanmar area type breaks its lines between words, which those scripts don't separate with spaces, instead of running on to the next space; the words come from ICU4X's line segmenter (its dictionaries on the desktop, its smaller LSTM models on the web). The font menu's script filter has Thai beside Japanese and Latin: the families whose character map has Thai. With craft-fonts' Noto Sans Thai, Thai is set in it where no Thai font is installed, and the web build fetches it before the app starts, so Thai no longer shows as empty boxes there. With the Type tool, Ctrl/Alt+arrows and double-click take Thai, Lao, Khmer and Myanmar text word by word (ภาษา, ไทย…) instead of a whole run without spaces, and a word's vowel and tone marks (and combining accents) stay part of it. Releases embed the current craft-fonts (Noto Sans Thai, Noto Sans Arabic and Noto Sans CJK SC beside the Japanese fonts), as the other craft apps' releases do. Object › Compound Path › Make cuts holes where the paths in front overlap the backmost one, also when they were drawn running the same way (#1197). On Windows, Direct3D 12 shaders compile with FXC, part of Windows, so a dxcompiler.dll another program left on the search path without its dxil.dll no longer loses the graphics device at start-up and drops the app to OpenGL (#1194). With anchor points selected by Direct Selection, the Align buttons align those points (to their own bounds or the artboard) instead of moving the whole path (#1180). With Smart Guides, an object grabbed by an anchor and dropped near another object's anchor lands exactly on it, instead of its bounds lining up a fraction of a point beside it (#955). .ai import: a copied area type or type-on-a-path object now sits where its own art puts it, not on top of the original (#1208). EPS/.ai import: a font is found by its exact PostScript name before the name is split, so families with hyphens keep their name (#1207). EPS/.ai import: type from the text document keeps its line spacing across paragraphs instead of applying the paragraph spacing twice (#1206). Brazilian Portuguese translates the remaining UI labels and engine messages (#1205). Czech joins the complete languages: panels, dialogs, plurals and status and error messages (#1189). Type: Digits (Character panel menu, with the Middle Eastern options) draws every digit in the Default, Arabic, Hindi or Farsi numeral system (#1201). Linux: under Wayland, Automatic draws on the GPU the compositor says it draws with, so a hybrid laptop whose compositor runs on the discrete GPU no longer opens an empty window (#1200). Large documents: editing a big selection takes time linear in the document (a 25,000-path nudge 3.5 s → 48 ms), live geometry effects are kept across frames, Pathfinder Trim, Merge, Crop and Outline are 9–20× faster with identical output, and undo history holds 41% less (#1173). | -| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) | +| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) | | 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day | | 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections | | 2026-10-07 | v0.4.0; issue fixes; Layers panel rework | diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md index 0b8fce9215..5f57c94a62 100644 --- a/assets/app-icon/README.md +++ b/assets/app-icon/README.md @@ -16,9 +16,7 @@ head-and-shoulders portrait on a full-bleed field, no frame or roundel, with the **Tile:** `viewBox="0 0 512 512"`, a rounded square with `rx=112`, clipped. The macOS files add Apple's transparent margin (tile = 824/1024 of the canvas); Windows and Linux use the tile edge to edge. -**Provenance:** the owner's original ArtCraft drawing (2880 px, keyed to the palette), vectorised with -craftrules `assets/logo-options/_tools/vectorize_tile.py`. The drawing is kept in craftrules at -`craftrules/assets/app-icons/vectorcraft/source.png`, not here. `vectorcraft.svg` is the canonical +**Provenance:** the owner's original ArtCraft drawing, vectorised. `vectorcraft.svg` is the canonical artwork. Licence: see `LICENSE.txt`. ## Files diff --git a/contributors/people.toml b/contributors/people.toml index 80ba8da523..0134ded6cb 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -1,13 +1,10 @@ -# Contributor names for the About window (craftrules standards/contributors.md). +# Contributor names for the About window. # # Everyone who contributes is credited by GitHub username automatically. Anything more is up to # you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a # maintainer may add a person only when that person asked, recording where in consent_source. # -# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \ -# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name] -# -# Or copy this block, replacing your-github-username (every field is optional): +# Copy this block, replacing your-github-username (every field is optional): # # [people.your-github-username] # real_name = "Your Name" # shown when the viewer picks "Real name" diff --git a/crates/text/src/craft_fonts.rs b/crates/text/src/craft_fonts.rs index b936e1f903..b15b77ec72 100644 --- a/crates/text/src/craft_fonts.rs +++ b/crates/text/src/craft_fonts.rs @@ -2,8 +2,7 @@ //! //! Built with `CRAFT_FONTS_DIR=`, [`CRAFT_FONTS`] holds every font in its //! manifest (Japanese UI and document fonts, Arabic fonts and Noto Sans Thai); built without it, -//! it is empty and that text falls back to the installed system fonts. Release builds always set it. See `build.rs` -//! and craftrules `standards/fonts.md`. +//! it is empty and that text falls back to the installed system fonts. Release builds always set it. See `build.rs`. /// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`). pub struct CraftFont { diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 22fa00b263..737032a551 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,8 +1,7 @@ -//! Bake the contributor credits into the binary as static Rust tables (craftrules -//! standards/contributors.md). Nothing is read at run time. +//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time. //! -//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules -//! scripts/contributors.py, which also marks whose people.toml entry it verified. +//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose +//! people.toml entry is verified. //! - contributors/people.toml: the names people chose for themselves. A name is used only when the //! JSON says the entry was verified (`people_entry`). //! diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 1f0a1dbbc5..35d014dc33 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -1,6 +1,6 @@ //! The About window's credits: every contributor (GitHub login always; display and real names only //! with recorded consent) and the AI models that helped. The tables are baked into the binary by -//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is +//! build.rs from contributors/contributors.json; nothing is //! read at run time. use std::cmp::Ordering; diff --git a/docs/contributors.md b/docs/contributors.md index a3e731fe09..a51eaf20c7 100644 --- a/docs/contributors.md +++ b/docs/contributors.md @@ -1,16 +1,14 @@ # Contributors in the About window **About ▸ Contributors** credits everyone who contributed to VectorCraft, and **About ▸ Models** credits -the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard -[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md); -this page is the local copy of the decision. +the AI models named in `Co-Authored-By` trailers. ## Decision - Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from disk or the network at run time; the web build has them too): - [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub - username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit. + username. Never hand-edit. - [`contributors/people.toml`](../contributors/people.toml): the names people chose for themselves. **Each contributor adds only their own entry**, in their own PR. - Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced @@ -37,20 +35,5 @@ display_name = "Nickname" sync_github_name = true # use the name on your public GitHub profile as display name ``` -or, with craftrules checked out next to `craft-apps/`: - -```sh -python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name -``` - -Names show after the next refresh: the script checks that each entry was committed by its own -user (or by a maintainer with a `consent_source`). Delete your entry to remove your names. - -## Refreshing (maintainers) - -```sh -python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh -python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only -``` - -Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded. +Names show for entries committed by their own user (or by a maintainer with a `consent_source`). +Delete your entry to remove your names. Git author names and emails are never recorded. diff --git a/docs/development.md b/docs/development.md index 261dde457c..6f4859d68d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -125,8 +125,7 @@ The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On ## Fonts: craft-fonts (optional build input) Font files are never committed to this repository. Fonts shared by the Crafting Apps live in -[storytold/craft-fonts](https://github.com/storytold/craft-fonts); the rules are in craftrules -[`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). To add +[storytold/craft-fonts](https://github.com/storytold/craft-fonts). To add a font, add it there. (The Latin fonts in `assets/fonts/` predate the rule and stay.) VectorCraft builds, tests and runs without craft-fonts. To embed its Japanese fonts (BIZ UDPGothic diff --git a/docs/releasing.md b/docs/releasing.md index ef476553b7..449729420c 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -65,7 +65,7 @@ pinned commit (the `ref:` of its craft-fonts checkout step) and builds with `CRA `CRAFT_FONTS_REQUIRED=1`, so releases embed its Japanese fonts and fail rather than ship without them (the web build embeds only the UI font, BIZ UDPGothic Regular, to stay small). The packages carry each embedded font's licence as `OFL-.txt` (`copy_font_licences` in `packaging/env.sh`). Bump the pins deliberately, in every job at once. -The rules are in craftrules `standards/fonts.md`, the build option in +The build option is in [`docs/development.md`](development.md) › Fonts. ### macOS diff --git a/docs/roadmap.md b/docs/roadmap.md index aa4fc86c49..86f4ea5a1c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -44,7 +44,7 @@ public menu documentation would be allowed). ## Alpha gate -The core-workflow gate from craftrules' progress-docs standard: what a typical professional does with +The core-workflow gate: what a typical professional does with Illustrator every day, checked end to end on the main platform (macOS) with save and reopen in the native `.vectorcraft` format (lossless, property-tested round trips in `crates/format/tests/prop_format.rs`). Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so VectorCraft passes and stays alpha. From ffad6cc20e25039c00fa988e7dfd2b1b69a0928b Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 04:25:47 +0000 Subject: [PATCH 04/98] docs: tidy wording --- ROADMAP.md | 4 ++-- docs/roadmap.md | 2 +- docs/target-app-parity.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 04396fffe1..e7d8f0bbad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -196,7 +196,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | 2026-10-01 | Renamed from DrawCraft to VectorCraft | | 2026-09-30 | Repository history begins | -The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/roadmap.md#progress-log-before-the-progress-docs-standard). +The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap.md#earlier-progress-log). ## Revision history @@ -223,7 +223,7 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro | 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. | | 2026-10-14 | minor | Progress log row for the merges of 2026-10-14 (#1641–#1808 as listed there) and Image Trace's #1647 and #1657; the 2026-10-13 row now says that a guide's position in the Transform panel (#1659) is not established by the documentation found and is kept as a convenience pending the owner's decision; headline numbers unchanged. | | 2026-10-13 | minor | Control transport deadlines (#1726): idle and partial clients and blocked replies release connection slots; parity scores and estimates unchanged. | -| 2026-10-10 | minor | Method aligned with the standard, no new evidence: full ready for real work is the additive weighted sum, ~62%; hours per audience; beta ~13 points away | +| 2026-10-10 | minor | Method changed, no new evidence: full ready for real work is the additive weighted sum, ~62%; hours per audience; beta ~13 points away | | 2026-10-10 | minor | Added the essentials-user readiness score (~75%) | | 2026-10-10 | minor | Ready for real work re-examined against the issue tracker and user feedback: 50% → 55% (full), 65% mainstream added; see target-app-parity.md | | 2026-10-10 | minor | Applied the core-workflow gate (docs/roadmap.md › Alpha gate): passes, stage stays alpha | diff --git a/docs/roadmap.md b/docs/roadmap.md index 86f4ea5a1c..5f6245138e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -113,7 +113,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V Milestone estimates predate the 2026-10-10 re-measure for M1–M14; the area table in [target-app-parity.md](target-app-parity.md) is authoritative when they disagree. -## Progress log before the progress-docs standard +## Earlier progress log The ROADMAP's "last updated" line as of 2026-10-10, kept as the record of what landed up to then: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index edcba39a94..aef1c3790d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -178,7 +178,7 @@ review parallelize less and need humans. ### Full Illustrator: additive weighted sum over the dimensions -The standard's method, the same in every app: a weighted **sum** over the dimensions, with written weights. +The method: a weighted **sum** over the dimensions, with written weights. Values are from [By dimension](#by-dimension). | Dimension | Weight | Value | Contribution | @@ -1638,7 +1638,7 @@ add-on. | 2026-10-11 | minor | Type advanced: Proportional Metrics in vertical type (`vpal`, #966) leaves the missing list; score and hours unchanged at this rounding (estimated) | | 2026-10-11 | minor | Shape Builder #893 fixed (planar-map faces share their corners exactly, so merges leave no seams); Path operations stays 80% and Shape Builder 70% while #937 is open (estimated) | | 2026-10-10 | minor | Merged main: initial live Revolve landed (#846). 3D 0% → ~8% (50–85 h), menu stubs 28 → 27; depth, readiness and totals unchanged within rounding | -| 2026-10-10 | minor | Method aligned with the standard, no new evidence: the full ready-for-real-work number is the additive weighted sum over the dimensions (~62%). The multiplicative 55% from earlier today is withdrawn, and the 50% before it was an unbroken-down judgement. Added hours to ~95% for each audience (full 340–560 h, mainstream 230–380 h, essentials 60–105 h). Beta distance is now ~13 points | +| 2026-10-10 | minor | Method changed, no new evidence: the full ready-for-real-work number is the additive weighted sum over the dimensions (~62%). The multiplicative 55% from earlier today is withdrawn, and the 50% before it was an unbroken-down judgement. Added hours to ~95% for each audience (full 340–560 h, mainstream 230–380 h, essentials 60–105 h). Beta distance is now ~13 points | | 2026-10-10 | minor | Added the essentials-user readiness score (~75%): 16 core features weighted, depth ~91%, discounted for launch, discoverability and files people send | | 2026-10-10 | minor | Second look at ready for real work at the owner's request: 50% → 55% full Illustrator, and a new mainstream-illustrator figure of 65%. Why: the old 25-point discount was a judgement with no breakdown. Now each discount is written down and checked against the tracker: 86% of 79 core-drawing issues closed, 20 of 28 `.ai`/EPS issues fixed and the open ones about structure, praise from 15 people, no reports of switching; dimensions a typical illustrator rarely hits are taken out of readiness. Feature breadth, depth and hours unchanged | | 2026-10-10 | minor | Stage checked against the core-workflow gate: passes, stays alpha | From 91e97c92ddc595240f00068df7985eca12ada63b Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 04:42:43 +0000 Subject: [PATCH 05/98] updates: show check results with craft-update's CheckResultDialog --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ui-egui/src/i18n/bs.tsv | 3 --- crates/ui-egui/src/i18n/ja.tsv | 3 --- crates/ui-egui/src/i18n/sv.tsv | 3 --- crates/ui-egui/src/updates_ui.rs | 40 ++++++++++++++++++++------------ 6 files changed, 27 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7703d3e33b..62f084afc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1047,7 +1047,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "chrono", "dunce", diff --git a/Cargo.toml b/Cargo.toml index 457a284d23..b5b4312eae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392c # desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.) craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } # 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 = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } vectorcraft-color = { path = "crates/color" } diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 229fb2b662..d32d915036 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5832,10 +5832,7 @@ swatch White, Black Bijela, crna swatch Yellow Žuta swatch Yellow Green Žuto-zelena Check for Updates… Provjeri ažuriranja… - Checking for updates… Provjera ažuriranja… - Could not check for updates:\n{error} Nije moguće provjeriti ažuriranja:\n{error} VectorCraft Updates VectorCraft ažuriranja - VectorCraft {version} is currently the newest version available. VectorCraft {version} je trenutno najnovija dostupna verzija. Preparing the image… Priprema slike… Tracing the colors… Praćenje boja… Units & Scale Jedinice i skala diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7244a27c6e..2542ac6636 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -16,10 +16,7 @@ # names, OpenType, the built-in workspace names and the bracketed perspective grid preset names. About VectorCraft VectorCraft について Check for Updates… アップデートを確認… - Checking for updates… アップデートを確認中… - Could not check for updates:\n{error} アップデートを確認できませんでした:\n{error} VectorCraft Updates VectorCraft のアップデート - VectorCraft {version} is currently the newest version available. VectorCraft {version} は最新バージョンです。 All Scripts すべての文字 Anchors: アンカー: Any Kind すべての種類 diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 851441feed..3da858206d 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5875,8 +5875,6 @@ anchor Points: Punkter: Arrow Scale: Pilskala: Arrow Style: Pilstil: Check for Updates… Sök efter uppdateringar… - Checking for updates… Söker efter uppdateringar… - Could not check for updates:\n{error} Det gick inte att söka efter uppdateringar:\n{error} Dimension Lines Måttlinjer Dimension Text Måttal Extension Lines Måttgränslinjer @@ -5898,7 +5896,6 @@ anchor Points: Punkter: Tracing the colors… Vektoriserar färgerna… Units & Scale Enheter & skala VectorCraft Updates Uppdateringar för VectorCraft - VectorCraft {version} is currently the newest version available. VectorCraft {version} är för närvarande den senaste tillgängliga versionen. Yonroku-ban Yonroku-ban @msg image '{key}' has no pixels and was left out bilden '{key}' har inga pixlar och utelämnades @msg not an RTF file: it doesn't start with {\\rtf inte en RTF-fil: den börjar inte med {\\rtf diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index ee9d09b71f..a62a221c5f 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -5,7 +5,9 @@ use serde_json::Value; use crate::VectorcraftApp; #[cfg(not(target_arch = "wasm32"))] -use craft_update::updater::{Action, Status, Updater}; +use craft_update::updater::{Action, Updater}; +#[cfg(not(target_arch = "wasm32"))] +use craft_update::{CheckResultDialog, CheckResultResponse}; pub(crate) struct Runtime { #[cfg(not(target_arch = "wasm32"))] @@ -18,7 +20,10 @@ 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, + /// The web build has no updater; its window only says so. + #[cfg(target_arch = "wasm32")] pub(crate) status_message: Option, } @@ -34,6 +39,7 @@ impl Default for Runtime { #[cfg(not(target_arch = "wasm32"))] repaint: None, manual_window_open: false, + #[cfg(target_arch = "wasm32")] status_message: None, } } @@ -51,6 +57,7 @@ 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(); } @@ -90,18 +97,25 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close), Action::None => {} } - // The window answers Help ▸ Check for Updates, so it follows the updater's status. + // An available update goes to the prompt above, which replaces the result dialog. let rt = &mut app.updates_rt; - (rt.manual_window_open, rt.status_message) = match rt.updater.status() { - Status::Idle => (false, None), - Status::Checking => (true, Some(tl!("Checking for updates…").to_string())), - Status::UpToDate => { - (true, Some(tl!("VectorCraft {version} is currently the newest version available.").replace("{version}", env!("CARGO_PKG_VERSION")))) + if rt.manual_window_open && rt.updater.prompt_open() { + rt.manual_window_open = false; + } + if rt.manual_window_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, + CheckResultResponse::CheckAgain => rt.updater.check_now(ctx), } - Status::Failed(e) => (true, Some(tl!("Could not check for updates:\n{error}").replace("{error}", e))), - }; + } + // Closing the dialog dismisses the outcome it showed. + if !rt.manual_window_open { + rt.updater.dismiss_status(); + } } + #[cfg(target_arch = "wasm32")] if app.updates_rt.manual_window_open { let mut open = true; egui::Window::new(tl!("VectorCraft Updates")) @@ -123,17 +137,13 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { app.updates_rt.manual_window_open = false; } } - - // Closing the window dismisses the outcome it showed. - #[cfg(not(target_arch = "wasm32"))] - if !app.updates_rt.manual_window_open { - app.updates_rt.updater.dismiss_status(); - } } #[cfg(test)] mod tests { use super::*; + #[cfg(not(target_arch = "wasm32"))] + use craft_update::updater::Status; use vectorcraft_engine::Session; #[test] From 8579aa07e36b8ba1a8290462ec8f1c398498d3f2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 02:08:52 -0700 Subject: [PATCH 06/98] Shortcuts: in the web build a key-position shortcut uses the key of the same press eframe's web build sends a keystroke's text before its key press. On a layout without Latin letters, the one-key shortcuts paired each text with the last key press before it, so in the web build a lone keystroke had no key and ran no shortcut, and a second keystroke in the same frame used the first one's key. Each text now takes the press right before it, unless an earlier text in the frame took that press, and otherwise the press right after it. The one-key shortcuts and the shortcut names a tool sees while it is busy use the same pairing. The desktop order (key, then text) pairs as before. --- crates/ui-egui/src/shortcuts.rs | 48 ++++++++++++++++++++------- crates/ui-egui/src/shortcuts/tests.rs | 25 ++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index bb0cb06e09..086b32bd58 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -358,15 +358,18 @@ fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) { // Single-key tool shortcuts (no Cmd/Ctrl/Alt). With a layout without Latin letters (Persian, // Arabic, Russian, Greek…) the key's position stands in for what it types (#793). let events: Vec<(String, Modifiers)> = ctx.input(|i| { - let mut physical = None; + let typed_by = typing_presses(&i.events); i.events .iter() - .filter_map(|e| match e { - egui::Event::Key { physical_key, pressed: true, .. } => { - physical = *physical_key; - None + .zip(typed_by) + .filter_map(|(e, by)| match e { + egui::Event::Text(t) => { + let physical = match by.and_then(|k| i.events.get(k)) { + Some(egui::Event::Key { physical_key, .. }) => *physical_key, + _ => None, + }; + Some((latin(t, physical), i.modifiers)) } - egui::Event::Text(t) => Some((latin(t, physical), i.modifiers)), _ => None, }) .collect() @@ -843,18 +846,39 @@ fn latin(text: &str, physical: Option) -> String { } } +/// For each event, the index of the key press that typed it when it is text: the press right +/// before it (egui-winit) unless an earlier text took that press, or else the press right after it +/// (eframe's web build with no text field focused). Each press types at most one text. +fn typing_presses(events: &[egui::Event]) -> Vec> { + let is_press = |k: usize| matches!(events.get(k), Some(egui::Event::Key { pressed: true, .. })); + let mut taken = None; + events + .iter() + .enumerate() + .map(|(j, e)| { + if !matches!(e, egui::Event::Text(_)) { + return None; + } + let before = j.checked_sub(1).filter(|&k| is_press(k) && taken != Some(k)); + let by = before.or_else(|| Some(j + 1).filter(|&k| is_press(k))); + taken = by.or(taken); + by + }) + .collect() +} + /// Each new press's shortcut names, first match first, and the letter it types, without Cmd, Ctrl /// or Alt. A text-only press counts as well (Shift+~ arrives as the text `~` with Shift), unless -/// the key that typed it repeated. A text's key is the press right before it (egui-winit) or, -/// failing that, right after it (eframe's web build with no text field focused). Text reads its -/// key's modifiers, or the latest modifier change (`held` before any), and tries its key's own -/// name first. +/// the key that typed it repeated. A text's key is the one `typing_presses` pairs it with. Text +/// reads its key's modifiers, or the latest modifier change (`held` before any), and tries its +/// key's own name first. fn shortcut_presses(events: &[egui::Event], held: Modifiers) -> Vec<(Vec, Option)> { let key_name = |key: &Key, m: &Modifiers| if m.shift { format!("Shift+{}", key.name()) } else { key.name().to_string() }; let press_at = |j: Option| match j.and_then(|j| events.get(j)) { Some(egui::Event::Key { key, pressed: true, repeat, modifiers, .. }) => Some((*repeat, *modifiers, key_name(key, modifiers))), _ => None, }; + let typed_by = typing_presses(events); let mut latest = held; events .iter() @@ -868,8 +892,8 @@ fn shortcut_presses(events: &[egui::Event], held: Modifiers) -> Vec<(Vec Some((vec![key_name(key, modifiers)], letter_of(*key))) } egui::Event::Text(t) => { - let typed_by = press_at(j.checked_sub(1)).or_else(|| press_at(Some(j + 1))); - let (repeated, mods, key_name) = typed_by.map_or((false, latest, None), |(r, m, n)| (r, m, Some(n))); + let (repeated, mods, key_name) = + press_at(typed_by.get(j).copied().flatten()).map_or((false, latest, None), |(r, m, n)| (r, m, Some(n))); if repeated || mods.command || mods.alt || mods.ctrl { return None; } diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 553c57df4a..49c41536e6 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -1470,6 +1470,31 @@ fn one_key_shortcuts_follow_the_key_on_layouts_without_latin_letters() { assert_eq!(app.session.tool_id(), "directSelection", "AZERTY: the letter typed"); } +/// The web build sends a keystroke's text before its key press: the one-key shortcut on a layout +/// without Latin letters still goes by the key of the same keystroke, and two keystrokes in one +/// frame each use their own key, in either order. +#[test] +fn one_key_shortcuts_use_the_key_of_the_same_keystroke_in_either_order() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).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()); + frame(&mut app, vec![text("з"), press(Key::P)]); + assert_eq!(app.session.tool_id(), "pen", "text before its key"); + frame(&mut app, vec![text("м"), press(Key::V), text("з"), press(Key::P)]); + assert_eq!(app.session.tool_id(), "pen", "the second text uses the second key"); + app.select_tool("pen"); + frame(&mut app, vec![press(Key::P), text("з"), press(Key::V), text("м")]); + assert_eq!(app.session.tool_id(), "selection", "key before its text"); + let names = |events: &[egui::Event]| -> Vec { + shortcut_presses(events, Modifiers::NONE).into_iter().filter_map(|(n, _)| n.into_iter().next()).collect() + }; + let web = names(&[text("м"), press(Key::V), text("з"), press(Key::P)]); + assert_eq!(web, ["V", "V", "P", "P"], "each text tries its own key's name first"); + let desktop = names(&[press(Key::V), text("м"), press(Key::P), text("з")]); + assert_eq!(desktop, ["V", "V", "P", "P"]); +} + #[test] fn plus_typed_any_way_zooms_in() { assert_eq!(parse("Cmd++"), parse("Cmd+="), "`+` and `=` are one chord key"); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index aef1c3790d..a4207f302d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -617,6 +617,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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. - 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). - While the Type tool edits text, a character typed with AltGr is typed and runs no command. Firefox on Windows reports AltGr as Ctrl and Alt held, so in the web build AltGr+L (ł on a Polish layout) does not also open Layer Options for a new layer, and AltGr+Q (@ on a German layout) does not also run Reset Tracking and Kerning. A Ctrl+Alt chord that types no character runs its command. + - On a layout without Latin letters, a one-key shortcut goes by the position of the key that typed the character, in the web build too, where the character arrives before its key press: on a Russian layout the P key (`з`) chooses the Pen, and two keys pressed in one frame each run their own shortcut. - With the Artboard tool, Cmd/Ctrl+A after a click on a row of the Artboards panel selects every artboard, as the macOS menu bar's Select All does then; the active artboard stays active, and the art's selection does not change. After a press outside the panel's list, Cmd/Ctrl+A selects every object again. It used to select every object after a click in the panel too. Public reference: [Select artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/select-artboards.html). - Cmd/Ctrl+/ (`appearance.addFill`) and Cmd/Ctrl+Alt+/ (`appearance.addStroke`) add a new fill and a new stroke to the selected objects, as the Appearance panel's Add New Fill and Add New Stroke buttons do, on the keys 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 adding a new fill and a new stroke. Keyboard Shortcuts lists both under Window › Appearance. - Cmd/Ctrl+Alt+Shift+. and , (`type.size.increaseByFive`, `type.size.decreaseByFive`) step the font size of the selected type, or of the text the Type tool has selected, by five times the Size/Leading increment (Preferences › Type); Cmd/Ctrl+Shift+. and , still step it by one increment. From 012773959762773fb96a4120fd6937e02d0c43da Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 02:33:38 -0700 Subject: [PATCH 07/98] Shortcuts: a text with no key press of its own leaves the next keystroke's press to that keystroke's text A text-only event right before a desktop keystroke in the same frame ([text, key, text]) took the key press as the press after it, which left the keystroke's own text with no key. A text now takes the press after it only when every later text in the same run of alternating texts and presses has a press after it too, as in the web build's [text, key] order; otherwise the press goes to the text after it. --- crates/ui-egui/src/shortcuts.rs | 19 +++++++++++++++++-- crates/ui-egui/src/shortcuts/tests.rs | 5 +++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 086b32bd58..39fbc2a2c8 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -848,9 +848,24 @@ fn latin(text: &str, physical: Option) -> String { /// For each event, the index of the key press that typed it when it is text: the press right /// before it (egui-winit) unless an earlier text took that press, or else the press right after it -/// (eframe's web build with no text field focused). Each press types at most one text. +/// (eframe's web build with no text field focused). Each press types at most one text. A text +/// takes the press after it only when every later text in the same run of alternating texts and +/// presses has a press after it too; otherwise the text after that press takes it as the press +/// before it, and this text has none. fn typing_presses(events: &[egui::Event]) -> Vec> { let is_press = |k: usize| matches!(events.get(k), Some(egui::Event::Key { pressed: true, .. })); + let is_text = |k: usize| matches!(events.get(k), Some(egui::Event::Text(_))); + // Whether the run of alternating texts and presses that starts with the text at `j` ends + // with a press. + let ends_with_press = |mut j: usize| loop { + if !is_press(j + 1) { + break false; + } + if !is_text(j + 2) { + break true; + } + j += 2; + }; let mut taken = None; events .iter() @@ -860,7 +875,7 @@ fn typing_presses(events: &[egui::Event]) -> Vec> { return None; } let before = j.checked_sub(1).filter(|&k| is_press(k) && taken != Some(k)); - let by = before.or_else(|| Some(j + 1).filter(|&k| is_press(k))); + let by = before.or_else(|| ends_with_press(j).then_some(j + 1)); taken = by.or(taken); by }) diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 49c41536e6..e07c31a74e 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -1486,6 +1486,11 @@ fn one_key_shortcuts_use_the_key_of_the_same_keystroke_in_either_order() { app.select_tool("pen"); frame(&mut app, vec![press(Key::P), text("з"), press(Key::V), text("м")]); assert_eq!(app.session.tool_id(), "selection", "key before its text"); + // A text with no key press of its own right before a desktop keystroke leaves that press to + // the keystroke's text. + assert_eq!(typing_presses(&[text("p"), press(Key::V), text("м")]), [None, None, Some(1)]); + frame(&mut app, vec![text("p"), press(Key::V), text("м")]); + assert_eq!(app.session.tool_id(), "selection", "the text after the key uses it"); let names = |events: &[egui::Event]| -> Vec { shortcut_presses(events, Modifiers::NONE).into_iter().filter_map(|(n, _)| n.into_iter().next()).collect() }; From d3ea958f8f8240fb42f5d3030809d7328f9b4a6c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 02:47:09 -0700 Subject: [PATCH 08/98] Shortcuts: a modifier key press is never the key that typed a text In the web build, pressing Shift and then a letter in one frame sends the Shift press, the letter's text and then the letter's key press. The text paired with the Shift press as the press before it, so a key-position shortcut looked up Shift instead of the letter, and a held Shift's repeated presses dropped the text as a repeat. Modifier key presses now never pair with a text. --- crates/ui-egui/src/shortcuts.rs | 5 +++-- crates/ui-egui/src/shortcuts/tests.rs | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 39fbc2a2c8..435758b85d 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -848,12 +848,13 @@ fn latin(text: &str, physical: Option) -> String { /// For each event, the index of the key press that typed it when it is text: the press right /// before it (egui-winit) unless an earlier text took that press, or else the press right after it -/// (eframe's web build with no text field focused). Each press types at most one text. A text +/// (eframe's web build with no text field focused). Modifier key presses type no text. Each press +/// types at most one text. A text /// takes the press after it only when every later text in the same run of alternating texts and /// presses has a press after it too; otherwise the text after that press takes it as the press /// before it, and this text has none. fn typing_presses(events: &[egui::Event]) -> Vec> { - let is_press = |k: usize| matches!(events.get(k), Some(egui::Event::Key { pressed: true, .. })); + let is_press = |k: usize| matches!(events.get(k), Some(egui::Event::Key { key, pressed: true, .. }) if !is_modifier(*key)); let is_text = |k: usize| matches!(events.get(k), Some(egui::Event::Text(_))); // Whether the run of alternating texts and presses that starts with the text at `j` ends // with a press. diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index e07c31a74e..6bce0cc6fb 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -1491,6 +1491,8 @@ fn one_key_shortcuts_use_the_key_of_the_same_keystroke_in_either_order() { assert_eq!(typing_presses(&[text("p"), press(Key::V), text("м")]), [None, None, Some(1)]); frame(&mut app, vec![text("p"), press(Key::V), text("м")]); assert_eq!(app.session.tool_id(), "selection", "the text after the key uses it"); + // A modifier key press does not type text, so the web text after Shift uses the key after it. + assert_eq!(typing_presses(&[press(Key::ShiftLeft), text("З"), press(Key::P)]), [None, Some(2), None]); let names = |events: &[egui::Event]| -> Vec { shortcut_presses(events, Modifiers::NONE).into_iter().filter_map(|(n, _)| n.into_iter().next()).collect() }; From 2d9ceb8137b96a434908bfd0b51f131eb3ef7243 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 02:51:09 -0700 Subject: [PATCH 09/98] Shortcuts: rewrap the typing_presses doc comment --- crates/ui-egui/src/shortcuts.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 435758b85d..504e84b2d7 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -849,10 +849,9 @@ fn latin(text: &str, physical: Option) -> String { /// For each event, the index of the key press that typed it when it is text: the press right /// before it (egui-winit) unless an earlier text took that press, or else the press right after it /// (eframe's web build with no text field focused). Modifier key presses type no text. Each press -/// types at most one text. A text -/// takes the press after it only when every later text in the same run of alternating texts and -/// presses has a press after it too; otherwise the text after that press takes it as the press -/// before it, and this text has none. +/// types at most one text. A text takes the press after it only when every later text in the same +/// run of alternating texts and presses has a press after it too; otherwise the text after that +/// press takes it as the press before it, and this text has none. fn typing_presses(events: &[egui::Event]) -> Vec> { let is_press = |k: usize| matches!(events.get(k), Some(egui::Event::Key { key, pressed: true, .. }) if !is_modifier(*key)); let is_text = |k: usize| matches!(events.get(k), Some(egui::Event::Text(_))); From 663e691908ad6bf8c4c825a45db0637a755ed27e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 02:35:20 -0700 Subject: [PATCH 10/98] Fix #4864: a search pick of an Asset Export command opens the panel The command palette runs a picked command with no params. Export Assets then failed with "no assets yet" (or, with assets collected, exported to no folder), and Remove, Rename and Asset Export Settings failed or did nothing. Run with no params from the palette, a menu or a shortcut, these four commands now show the Asset Export panel, and leave it open when it already shows, docked or floating. Calls with params are unchanged. --- crates/ui-egui/src/menus/invoke.rs | 13 +++++++++++-- crates/ui-egui/src/palette.rs | 25 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 75f7462c7e..382c730aa1 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -256,8 +256,8 @@ fn open_view_dialog(app: &mut VectorcraftApp, id: &str, p: &Value) -> bool { false } -/// The dialogs the selection, raster, wrap, blend and Expand commands open when called without params; whether `id` -/// was one of them. +/// The dialogs the selection, raster, wrap, blend and Expand commands open, and the panel the Asset Export commands +/// open, when called without params; whether `id` was one of them. fn open_object_dialog(app: &mut VectorcraftApp, id: &str, p: &Value) -> bool { // Save Selection…: a name dialog, starting from the first free "Selection N". if id == "select.save" && p.as_object().is_none_or(|o| o.is_empty()) { @@ -315,5 +315,14 @@ 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()) { + if let Err(e) = crate::panel_docking::reveal_panel(app, crate::panels::asset_export::ID) { + app.status(e); + } + return true; + } false } diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index a6cc61e230..f7728f4c33 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -199,6 +199,31 @@ mod tests { assert_eq!((st.selection.len(), st.selection.key), (2, None), "both objects stay selected, without a key"); } + /// #4864: a pick of an Asset Export command, which the palette runs with no params, opens the + /// 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"]; + 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()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.ui.status.clear(); + assert!(!crate::panel_docking::showing(&app.ui, crate::panels::asset_export::ID), "{id}"); + // Run a pick the way `show` runs it. + menus::invoke(&mut app, id, json!({})); + assert!(crate::panel_docking::showing(&app.ui, crate::panels::asset_export::ID), "{id}: {}", app.ui.status); + assert!(!app.ui.status.contains("invalid parameters"), "{id}: {}", app.ui.status); + // A second pick leaves the open panel open, docked or floating. + menus::invoke(&mut app, id, json!({})); + assert!(crate::panel_docking::showing(&app.ui, crate::panels::asset_export::ID), "{id}: picked twice"); + app.run("window.panel.float", json!({"panel": crate::panels::asset_export::ID})).unwrap(); + assert!(crate::panel_docking::showing(&app.ui, crate::panels::asset_export::ID), "{id}: floating"); + menus::invoke(&mut app, id, json!({})); + assert!(crate::panel_docking::showing(&app.ui, crate::panels::asset_export::ID), "{id}: floating, picked again"); + } + } + /// #1757: Up and Down move the highlighted result, wrapping at the ends; a new query starts /// from the first again. #[test] From 4e785b40ce1d07fabb4ba06646d43ce11e5a2710 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 03:12:49 -0700 Subject: [PATCH 11/98] Part of #4516: Windows renders start workers only for the cores the process may use The renderer sized its worker pool from available_parallelism, which on Windows counts every processor in the machine, also when the process's affinity mask allows fewer. A process allowed one core started 8 workers, and the renderer's flush spun on that core while the workers got no time, so renders crawled or stalled. The desktop app and vectorcraft-cli now read the affinity mask at startup (src/windows_cores.rs, shared) and pass the count to the renderer with vectorcraft_render::set_usable_cores. With one usable core the renderer renders on the calling thread. --- Cargo.lock | 1 + apps/vectorcraft-cli/Cargo.toml | 5 ++- apps/vectorcraft-cli/src/main.rs | 7 ++++ apps/vectorcraft/Cargo.toml | 6 ++- apps/vectorcraft/src/main.rs | 5 +++ apps/vectorcraft/src/windows_cores.rs | 59 +++++++++++++++++++++++++++ crates/render/src/lib.rs | 2 +- crates/render/src/threads.rs | 44 ++++++++++++++++++-- 8 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 apps/vectorcraft/src/windows_cores.rs diff --git a/Cargo.lock b/Cargo.lock index 62f084afc5..ff1d18be21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6065,6 +6065,7 @@ dependencies = [ "smithay-clipboard", "socket2", "vectorcraft-engine", + "vectorcraft-render", "vectorcraft-text", "vectorcraft-ui-egui", "wayland-backend", diff --git a/apps/vectorcraft-cli/Cargo.toml b/apps/vectorcraft-cli/Cargo.toml index cb9183315f..8f39e3b58d 100644 --- a/apps/vectorcraft-cli/Cargo.toml +++ b/apps/vectorcraft-cli/Cargo.toml @@ -34,8 +34,9 @@ winsafe = { version = "0.0.29", default-features = false, features = ["kernel", # DirectWrite's system font collection and GDI's font list (the desktop app's src/system_fonts.rs, # shared): fonts that font services such as Adobe Fonts load in place (#579, #1258). MIT OR -# Apache-2.0. -windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi"] } +# Apache-2.0. Win32_System_Threading has `GetProcessAffinityMask`, which the desktop app's +# src/windows_cores.rs, shared, uses to count the cores the process may run on. +windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Threading"] } # CoreText's font manager (the desktop app's src/mac_fonts.rs, shared): fonts that apps and font # managers register from their own folders (#579). Zlib OR Apache-2.0 OR MIT. diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index bba1976c1d..b7334586ca 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -49,6 +49,10 @@ mod prefs_dir; #[cfg(windows)] #[path = "../../vectorcraft/src/system_fonts.rs"] mod system_fonts; +/// The desktop app's affinity-mask reader, shared: renders start workers only for usable cores. +#[cfg(windows)] +#[path = "../../vectorcraft/src/windows_cores.rs"] +mod windows_cores; use serde_json::{Value, json}; use vectorcraft_engine::file_access::{self, AutomationRoots}; @@ -161,6 +165,9 @@ fn help() -> Result<(), String> { } fn main() -> ExitCode { + // Before the first render: the cores the process may run on. + #[cfg(windows)] + windows_cores::install(); // Before any font lookup: the fonts font services load (#579). #[cfg(windows)] system_fonts::install(); diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 0657c93c8a..7af8c51eff 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -54,7 +54,11 @@ pollster = "1" # Win32_UI_WindowsAndMessaging has `MSG` and `WM_POINTERDOWN`, which src/windows_stylus.rs uses to # read a pen's barrel button from its pointer messages. Win32_UI_Input_Pointer has # `GetPointerPenInfo`, which it uses to read the pen's pressure and which end of the pen is down. -windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } +# Win32_System_Threading has `GetProcessAffinityMask`, which src/windows_cores.rs uses to count the +# cores the process may run on. +windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Threading", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } +# The renderer's worker count follows those cores (src/windows_cores.rs). +vectorcraft-render = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] # The macOS menu bar (src/mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index e90977726d..1e2ab4fae6 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -50,6 +50,8 @@ mod system_fonts; mod tablet; mod window; mod window_frame; +#[cfg(windows)] +mod windows_cores; mod windows_stylus; mod xwayland; @@ -184,6 +186,9 @@ fn main() -> std::process::ExitCode { // Before desktop services, so every start-up warning is recorded (`logging`). let logger = logging::install(); vectorcraft_ui_egui::i18n::detect_system_lang_in_background(); + // Before the first render: the cores the process may run on. + #[cfg(windows)] + windows_cores::install(); // Before the first font scan (the app's start): the fonts font services load (#579). #[cfg(windows)] system_fonts::install(); diff --git a/apps/vectorcraft/src/windows_cores.rs b/apps/vectorcraft/src/windows_cores.rs new file mode 100644 index 0000000000..97d040550c --- /dev/null +++ b/apps/vectorcraft/src/windows_cores.rs @@ -0,0 +1,59 @@ +//! Windows: the cores the process may run on, from its affinity mask, for the renderer's worker +//! count (`vectorcraft_render::set_usable_cores`). `available_parallelism` counts every processor +//! in the machine, also when the process may run on fewer (started with `start /affinity`, or +//! pinned by a launcher). The desktop app and `vectorcraft-cli` (exports, MCP) both install it; the +//! CLI shares this file. +//! +//! `GetProcessAffinityMask` is a C API with no safe binding, hence the scoped `unsafe_code` +//! allowance. +#![allow(unsafe_code)] + +use windows::Win32::System::Threading::{GetCurrentProcess, GetProcessAffinityMask}; + +/// Tells the renderer how many cores the process may run on. +pub fn install() { + vectorcraft_render::set_usable_cores(usable_cores()); +} + +/// The number of cores in the process's affinity mask, or `None` when Windows reports none (a +/// process with threads in more than one processor group). +fn usable_cores() -> Option { + let (mut process, mut system) = (0usize, 0usize); + // SAFETY: `GetCurrentProcess` returns a pseudo-handle that needs no closing, and both pointers + // point at live `usize`s for the length of the call. + unsafe { GetProcessAffinityMask(GetCurrentProcess(), &mut process, &mut system) }.ok()?; + (process != 0).then(|| process.count_ones() as usize) +} + +#[cfg(test)] +mod tests { + /// A process that may run on one core renders on the calling thread. With workers, the + /// renderer's flush spins on that core while the workers it waits for get no time, and a render + /// crawls or stalls. + #[test] + fn a_process_limited_to_one_core_starts_no_render_workers() { + const CHILD: &str = "VECTORCRAFT_TEST_ONE_CORE"; + const NAME: &str = "windows_cores::tests::a_process_limited_to_one_core_starts_no_render_workers"; + if std::env::var_os(CHILD).is_some() { + assert_eq!(super::usable_cores(), Some(1)); + super::install(); + assert_eq!(vectorcraft_render::default_threads(), 0); + return; + } + // `start /affinity 1` runs this test again in a process allowed only the first core. + use std::os::windows::process::CommandExt; + let exe = std::env::current_exe().unwrap(); + let output = std::process::Command::new("cmd") + .raw_arg(format!("/C start \"\" /affinity 1 /wait /b \"{}\" --exact {NAME} --nocapture", exe.display())) + .env(CHILD, "1") + .env_remove("VECTORCRAFT_RENDER_THREADS") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"), + "one-core test failed:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr), + ); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d5c601dc13..8bf6b0d232 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -227,6 +227,6 @@ pub(crate) use text_geom::is_opentype_alternate; #[cfg(test)] use text_geom::text_geom; pub(crate) use threads::single_threaded_context; -pub use threads::{default_threads, set_default_threads}; +pub use threads::{default_threads, set_default_threads, set_usable_cores}; #[cfg(test)] mod tests; diff --git a/crates/render/src/threads.rs b/crates/render/src/threads.rs index eb1fb66eae..dfd6bf757d 100644 --- a/crates/render/src/threads.rs +++ b/crates/render/src/threads.rs @@ -9,14 +9,25 @@ pub(crate) fn single_threaded_context(w: u16, h: u16) -> RenderContext { /// Preferred rasterizer thread count set by the app (Preferences → Performance); negative = automatic. static THREADS_OVERRIDE: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1); +/// The cores the process may run on, as the app read them from the system; 0 = not known. +static USABLE_CORES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + /// Override the worker-thread count used by renderers created from now on (`None` = automatic). pub fn set_default_threads(n: Option) { THREADS_OVERRIDE.store(n.map_or(-1, i32::from), std::sync::atomic::Ordering::Relaxed); } -/// Rasterizer worker threads: 0 on wasm; otherwise one per core up to 8, leaving a core free. On a -/// 10-core machine 8 workers render a 50,000-path page 1.4× faster than 4 (9 are slower again); -/// the pixels don't depend on the count (`pixels_do_not_depend_on_the_thread_count`). +/// The number of cores the process may run on, when the app knows a smaller number than +/// `available_parallelism` gives (`None` = not known). On Windows `available_parallelism` counts +/// every processor in the machine, also when the process's affinity mask allows fewer. +pub fn set_usable_cores(n: Option) { + USABLE_CORES.store(n.unwrap_or(0), std::sync::atomic::Ordering::Relaxed); +} + +/// Rasterizer worker threads: 0 on wasm; otherwise one per core the process may run on, up to 8, +/// leaving a core free. On a 10-core machine 8 workers render a 50,000-path page 1.4× faster than 4 +/// (9 are slower again); the pixels don't depend on the count +/// (`pixels_do_not_depend_on_the_thread_count`). pub fn default_threads() -> u16 { #[cfg(target_arch = "wasm32")] { @@ -31,6 +42,31 @@ pub fn default_threads() -> u16 { if std::env::var_os("VECTORCRAFT_RENDER_THREADS").is_some() { return std::env::var("VECTORCRAFT_RENDER_THREADS").ok().and_then(|v| v.parse().ok()).unwrap_or(0); } - std::thread::available_parallelism().map(|n| (n.get().saturating_sub(1)).min(8) as u16).unwrap_or(0) + let usable = USABLE_CORES.load(std::sync::atomic::Ordering::Relaxed); + std::thread::available_parallelism().map(|n| automatic_threads(n.get(), usable)).unwrap_or(0) + } +} + +/// [`default_threads`] when automatic, on `cpus` processors of which the process may use `usable` +/// (0 = all). +fn automatic_threads(cpus: usize, usable: usize) -> u16 { + let cores = if usable == 0 { cpus } else { cpus.min(usable) }; + (cores.saturating_sub(1)).min(8) as u16 +} + +#[cfg(test)] +mod tests { + use super::automatic_threads; + + /// A process that may run on one core renders on the calling thread. With workers, vello_cpu's + /// flush spins on that core while the workers it waits for get no time, and a render crawls or + /// stalls. + #[test] + fn workers_follow_the_cores_the_process_may_use() { + assert_eq!(automatic_threads(24, 0), 8); + assert_eq!(automatic_threads(24, 1), 0); + assert_eq!(automatic_threads(24, 4), 3); + assert_eq!(automatic_threads(4, 16), 3); + assert_eq!(automatic_threads(1, 0), 0); } } From da9eaaacf8c9742b2cd07badd95c7c77d335a297 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 31 Oct 2026 19:43:13 +0700 Subject: [PATCH 12/98] Windows cores: test the affinity mask in-process A test that spawned the test binary with `start /affinity 1` ran while the control server's socket tests held connections; the child inherited them and reset one (idle_expiry_closes_without_writing_a_line failed every full run). The one-core worker count stays covered by `automatic_threads`. --- apps/vectorcraft/src/windows_cores.rs | 35 ++++++--------------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/apps/vectorcraft/src/windows_cores.rs b/apps/vectorcraft/src/windows_cores.rs index 97d040550c..b35c0876cf 100644 --- a/apps/vectorcraft/src/windows_cores.rs +++ b/apps/vectorcraft/src/windows_cores.rs @@ -27,33 +27,14 @@ fn usable_cores() -> Option { #[cfg(test)] mod tests { - /// A process that may run on one core renders on the calling thread. With workers, the - /// renderer's flush spins on that core while the workers it waits for get no time, and a render - /// crawls or stalls. + /// The mask Windows reports for this process gives at least one core and no more than the + /// machine has. (A child process started with `start /affinity` would test a one-core mask + /// end to end, but a process spawned while other tests hold sockets inherits them and resets + /// their connections; `automatic_threads` covers the one-core count.) #[test] - fn a_process_limited_to_one_core_starts_no_render_workers() { - const CHILD: &str = "VECTORCRAFT_TEST_ONE_CORE"; - const NAME: &str = "windows_cores::tests::a_process_limited_to_one_core_starts_no_render_workers"; - if std::env::var_os(CHILD).is_some() { - assert_eq!(super::usable_cores(), Some(1)); - super::install(); - assert_eq!(vectorcraft_render::default_threads(), 0); - return; - } - // `start /affinity 1` runs this test again in a process allowed only the first core. - use std::os::windows::process::CommandExt; - let exe = std::env::current_exe().unwrap(); - let output = std::process::Command::new("cmd") - .raw_arg(format!("/C start \"\" /affinity 1 /wait /b \"{}\" --exact {NAME} --nocapture", exe.display())) - .env(CHILD, "1") - .env_remove("VECTORCRAFT_RENDER_THREADS") - .output() - .unwrap(); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"), - "one-core test failed:\n{stdout}\n{}", - String::from_utf8_lossy(&output.stderr), - ); + fn the_affinity_mask_gives_the_cores_the_process_may_use() { + let cores = super::usable_cores().unwrap(); + let all = std::thread::available_parallelism().unwrap().get(); + assert!((1..=all).contains(&cores), "{cores} of {all}"); } } From 9163c22396c003d48f9c306ffe7cdeeaf2582065 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 00:54:56 -0700 Subject: [PATCH 13/98] Fix #4819: Convert to Smooth and Corner convert a selected blend's spine With a blend selected, the Contextual Task Bar's Corner and Smooth buttons failed with "select paths or anchor points", since `path.convertAnchors` only read paths. They now convert every point of the blend's spine, as they convert every anchor of a path selected whole, in one undo step. The key objects stay where they are. With Direct Selection, the Control bar and the Properties panel now show their Convert buttons for a selected blend too. --- crates/engine/src/cmd/path.rs | 26 +++++++++++++++++++-- crates/engine/src/tests/blendspine.rs | 29 ++++++++++++++++++++++++ crates/ui-egui/src/chrome/control_bar.rs | 9 ++++---- crates/ui-egui/src/chrome/tests.rs | 17 ++++++++++++++ 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 5450f3d3c4..3503ed14f5 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -112,7 +112,7 @@ pub fn specs() -> Vec { "Convert Anchor Points", [], None, - "{to: \"corner\"|\"smooth\"} convert the direct-selected anchors (all anchors of fully selected paths) as one undo step: corner retracts both handles, smooth pulls handles in line with the neighbours (smooth anchors keep theirs)", + "{to: \"corner\"|\"smooth\"} convert the direct-selected anchors (all anchors of fully selected paths, every point of a selected blend's spine) as one undo step: corner retracts both handles, smooth pulls handles in line with the neighbours (smooth anchors keep theirs)", has_selection, convert_anchors ), @@ -1123,7 +1123,11 @@ fn convert_anchors(s: &mut Session, p: &Value) -> Result { _ => return Err(bad(C, "`to` must be \"corner\" or \"smooth\"")), }; let t = anchor_targets(s)?; - if t.is_empty() { + // A selected blend converts every point of its spine, as a path selected whole does. + let st = s.doc()?; + let blends: Vec = + st.selection.objects.iter().copied().filter(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Blend { .. }))).collect(); + if t.is_empty() && blends.is_empty() { return Err(bad(C, "select paths or anchor points")); } s.edit("Convert Anchor Points", |d, _| { @@ -1138,6 +1142,24 @@ fn convert_anchors(s: &mut Session, p: &Value) -> Result { } } } + for id in &blends { + let Some(NodeKind::Blend { children, spec }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + let Some((mut path, anchors)) = vectorcraft_doc::live::pin_spine(children, spec) else { continue }; + let pinned = path.clone(); + let Some(sp) = path.subpaths.first_mut() else { continue }; + for ai in 0..sp.anchors.len() { + if smooth { + sp.smooth_anchor(ai); + } else if let Some(a) = sp.anchors.get_mut(ai) { + a.retract(); + } + } + // A spine the conversion leaves as it was stays as the blend has it. + if path != pinned { + spec.key_anchors = anchors.iter().map(|a| u32::try_from(*a).unwrap_or(u32::MAX)).collect(); + spec.spine = Some(path); + } + } Ok(()) })?; ok() diff --git a/crates/engine/src/tests/blendspine.rs b/crates/engine/src/tests/blendspine.rs index 78e9335ac3..0ab2aac5b6 100644 --- a/crates/engine/src/tests/blendspine.rs +++ b/crates/engine/src/tests/blendspine.rs @@ -115,6 +115,35 @@ fn spine_conversion_rejects_invalid_arguments_without_mutation() { } } +/// #4819: Convert Selected Anchor Points to Smooth or Corner (the Control bar's and the Contextual +/// Task Bar's buttons) converts the points of a selected blend's spine, as it converts every +/// anchor of a path selected whole, in one undo step, the keys staying put. +#[test] +fn converting_anchors_of_a_selected_blend_converts_its_spine() { + let mut s = session(); + let (a, b, g) = blend(&mut s); + s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap(); + s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 1, "x": 55, "y": 105})).unwrap(); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + let keys = spec(&s, g).key_anchors; + s.execute("path.convertAnchors", &json!({"to": "smooth"})).unwrap(); + let sp = spec(&s, g); + assert_eq!(sp.key_anchors, keys); + let middle = sp.spine.as_ref().unwrap().subpaths[0].anchors[1]; + assert_eq!(middle.p, Point::new(55.0, 105.0)); + assert!(middle.has_in() && middle.has_out(), "{middle:?}"); + assert!((middle.h_in.y - 105.0).abs() < 1e-9 && (middle.h_out.y - 105.0).abs() < 1e-9, "tangent to the chord between its neighbors: {middle:?}"); + assert!(middle.h_in.x < 55.0 && middle.h_out.x > 55.0, "{middle:?}"); + assert_eq!(center(&node(&s, a)), Point::new(5.0, 5.0)); + assert_eq!(center(&node(&s, b)), Point::new(105.0, 5.0)); + + s.execute("path.convertAnchors", &json!({"to": "corner"})).unwrap(); + let corner = spec(&s, g).spine.unwrap().subpaths[0].anchors.clone(); + assert!(corner.iter().all(|a| !a.has_in() && !a.has_out()), "{corner:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, g).spine.unwrap().subpaths[0].anchors[1], middle, "one undo step"); +} + #[test] fn moving_a_key_point_moves_the_key_and_the_other_way_round() { let mut s = session(); diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs index c1bdfefa57..79b6b54675 100644 --- a/crates/ui-egui/src/chrome/control_bar.rs +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -7,9 +7,9 @@ type AnchorButton<'a> = (&'a str, &'a str, &'a str, Option<&'a str>); #[derive(Clone, Copy, PartialEq, Eq)] pub enum AnchorControls { None, - /// Paths selected as a whole with a tool that edits anchors (Direct Selection, the Pen…), - /// which shows all their anchors selected: "Convert:" only, as removing or cutting at every - /// anchor isn't what they're for. + /// Paths or blends selected as a whole with a tool that edits anchors (Direct Selection, the + /// Pen…), which shows all their anchors selected: "Convert:" only, as removing or cutting at + /// every anchor isn't what they're for. A blend converts the points of its spine. Convert, /// Anchors direct-selected: "Convert:" and "Anchors:". All, @@ -23,7 +23,8 @@ pub fn anchor_controls(app: &VectorcraftApp) -> AnchorControls { if !st.selection.anchors.is_empty() { return AnchorControls::All; } - let paths = st.selection.objects.iter().any(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Path { .. }))); + let paths = + st.selection.objects.iter().any(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Path { .. } | NodeKind::Blend { .. }))); if paths && vectorcraft_tools::catalog::edits_anchors(app.session.tool_id()) { AnchorControls::Convert } else { AnchorControls::None } } diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 91a276e9b0..798777d7ea 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -1031,6 +1031,23 @@ fn the_control_bar_isolates_the_selected_object_and_exits_isolation_mode() { assert_eq!(isolation_button(&mut app, &ctx, vec![]), None, "the isolation bar ends pattern editing"); } +/// #4819: with Direct Selection, a selected blend shows the Control bar's and the Properties +/// panel's Convert buttons, as a path selected whole does; with the Selection tool it doesn't. +#[test] +fn a_blend_selected_with_direct_selection_shows_the_convert_buttons() { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let rect = |app: &mut crate::VectorcraftApp, x: f64| { + app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 20})).unwrap()["id"].as_u64().unwrap() + }; + let (a, b) = (rect(&mut app, 10.0), rect(&mut app, 200.0)); + let blend = app.run("object.blend.make", json!({"ids": [a, b], "steps": 1})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [blend]})).unwrap(); + assert!(super::anchor_controls(&app) == super::AnchorControls::None, "the Selection tool"); + app.select_tool("directSelection"); + assert!(super::anchor_controls(&app) == super::AnchorControls::Convert); +} + /// The Zoom tool's hint follows Performance › Animated Zoom (#394): a drag zooms as it goes, /// or (off) zooms into the area dragged across. #[test] From 7860931a3bd483de9c350909b87189ea4e36ffd2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 01:15:25 -0700 Subject: [PATCH 14/98] Select Same: Fill Color from a Live Paint face stays in its Live Paint group Select > Same > Fill Color and Fill & Stroke from a face of a Live Paint group now select the matching faces of that group only, and from any other object they select no Live Paint face. Before, they matched faces of every Live Paint group and paths outside them alike. --- crates/engine/src/cmd/select.rs | 34 ++++++++++++++++++----- crates/engine/src/tests/menucmds.rs | 42 +++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index b8b04482f1..462746771c 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -5,6 +5,7 @@ use std::collections::BTreeSet; use serde_json::{Value, json}; use vectorcraft_color::Paint; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; +use vectorcraft_tools::builder::is_live_paint; use super::wand::{fill_of, stroke_of}; use super::{ @@ -106,11 +107,11 @@ pub fn specs() -> Vec { "Fill Color", ["Select", "Same"], None, - "{} the objects filled with the first selected object's fill colour (any tint of its global or spot swatch, the same tint with prefs selectSameTintPercent); a member path of a compound path selected first compares as its compound path, except in isolation mode inside that compound path", + "{} the objects filled with the first selected object's fill colour (any tint of its global or spot swatch, the same tint with prefs selectSameTintPercent); a member path of a compound path selected first compares as its compound path, except in isolation mode inside that compound path; from a Live Paint face, the faces of its Live Paint group only, and from any other object, no Live Paint face", has_selection, |s, _| { let tint = s.prefs.select_same_tint_percent; - same(s, "select.same.fillColor", |a, b| same_paint(&fill_of(a), &fill_of(b), tint)) + same_fill(s, "select.same.fillColor", |a, b| same_paint(&fill_of(a), &fill_of(b), tint)) } ), cmd!( @@ -135,11 +136,11 @@ pub fn specs() -> Vec { "Fill & Stroke", ["Select", "Same"], None, - "{} the objects with the first selected object's fill and stroke colours (tints and compound paths as select.same.fillColor)", + "{} the objects with the first selected object's fill and stroke colours (tints, compound paths and Live Paint faces as select.same.fillColor)", has_selection, |s, _| { let tint = s.prefs.select_same_tint_percent; - same(s, "select.same.fillAndStroke", |a, b| { + same_fill(s, "select.same.fillAndStroke", |a, b| { same_paint(&fill_of(a), &fill_of(b), tint) && same_paint(&stroke_of(a).0, &stroke_of(b).0, tint) }) } @@ -684,9 +685,30 @@ fn same_reference<'a>(st: &'a DocState, cmd: &str) -> Result<(&'a Node, bool)> { /// [`paint_reported`] accepts. A compound path matches as a whole. In isolation mode inside a /// compound path, its member paths are matched instead. fn same(s: &mut Session, cmd: &str, eq: impl Fn(&Node, &Node) -> bool) -> Result { - let (refn, members) = same_reference(s.doc()?, cmd)?; + same_in(s, cmd, false, eq) +} + +/// Select > Same > Fill Color and Fill & Stroke: [`same`], kept to one side of a Live Paint group. +/// From a face or edge of a Live Paint group, only the faces and edges of that group match; from +/// any other object, no face or edge of a Live Paint group does +/// (). +fn same_fill(s: &mut Session, cmd: &str, eq: impl Fn(&Node, &Node) -> bool) -> Result { + same_in(s, cmd, true, eq) +} + +fn same_in(s: &mut Session, cmd: &str, live_paint: bool, eq: impl Fn(&Node, &Node) -> bool) -> Result { + let st = s.doc()?; + let (refn, members) = same_reference(st, cmd)?; + let group = live_paint_group(&st.doc, refn.id); let refn = refn.clone(); - select_where(s, cmd, &json!({}), |d, n| (if members { !n.is_container() } else { paint_reported(d, n) }) && eq(n, &refn)) + select_where(s, cmd, &json!({}), |d, n| { + (!live_paint || live_paint_group(d, n.id) == group) && (if members { !n.is_container() } else { paint_reported(d, n) }) && eq(n, &refn) + }) +} + +/// The Live Paint group `id` is a face or edge of. +fn live_paint_group(d: &Document, id: NodeId) -> Option { + d.parent_of(id).filter(|p| d.node(*p).is_some_and(is_live_paint)) } /// Select the objects [`walk_selectable`] visits that `f` accepts (not looking inside accepted diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index df6f7db084..ff11009b16 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -1813,6 +1813,48 @@ fn select_same_matches_compound_paths_on_their_own_paint() { assert_eq!(same(&mut s, outer, "select.same.shapeType"), vec![outer, inner]); } +/// Select > Same > Fill Color and Fill & Stroke from a Live Paint face select the faces of that +/// Live Paint group with the same fill, and nothing outside it. From an object outside every Live +/// Paint group, they select no face. +#[test] +fn select_same_fill_keeps_live_paint_faces_to_their_group() { + let mut s = session(); + let live = |s: &mut Session, y: f64| { + let a = rect(s, 0.0, y, 100.0, 100.0); + let b = rect(s, 50.0, y, 100.0, 100.0); + sel(s, &[a, b]); + id_of(&s.execute("livePaint.make", &json!({})).unwrap()) + }; + let paint = |s: &mut Session, g: NodeId, x: f64, y: f64, hex: &str| { + NodeId(s.execute("livePaint.fill", &json!({"group": g.0, "point": [x, y], "color": hex})).unwrap()["face"].as_u64().unwrap()) + }; + let g1 = live(&mut s, 0.0); + let g2 = live(&mut s, 300.0); + let [left, right] = [25.0, 125.0].map(|x| paint(&mut s, g1, x, 50.0, "#ff0000")); + let middle = paint(&mut s, g1, 75.0, 50.0, "#0000ff"); + let other = paint(&mut s, g2, 25.0, 350.0, "#ff0000"); + let [r1, r2] = [400.0, 450.0].map(|x| rect(&mut s, x, 0.0, 10.0, 10.0)); + for r in [r1, r2] { + fill(&mut s, r, "#ff0000"); + s.execute("paint.setStroke", &json!({"none": true, "ids": [r.0]})).unwrap(); + } + let same = |s: &mut Session, from: NodeId, cmd: &str| { + sel(s, &[from]); + s.execute(cmd, &json!({})).unwrap(); + let mut v = selected(s); + v.sort(); + v + }; + for cmd in ["select.same.fillColor", "select.same.fillAndStroke"] { + let mut faces = vec![left, right]; + faces.sort(); + assert_eq!(same(&mut s, left, cmd), faces, "{cmd} from a face"); + assert_eq!(same(&mut s, middle, cmd), vec![middle], "{cmd} from the blue face"); + assert_eq!(same(&mut s, other, cmd), vec![other], "{cmd} from the other group's face"); + assert_eq!(same(&mut s, r1, cmd), vec![r1, r2], "{cmd} from a path outside"); + } +} + /// Select by Color needs no object to start from: it matches fills and strokes as /// `document.inspect` reports them, `#rrggbb`. #[test] From 37211078c2bf08592cbe075f0e1b1e32adb1eb11 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 00:32:58 -0700 Subject: [PATCH 15/98] Fix #4837: the saved color scheme, accent and canvas color show at launch The first UI pass installed the default theme over the one the saved preferences had applied earlier in the same frame, and later frames saw the preferences as already applied. Classic, a non-default accent and a Canvas Color showed as the defaults until a preference changed, and again after the app took a new graphics context. The first pass now applies the saved preferences. --- crates/ui-egui/src/app.rs | 3 ++- crates/ui-egui/src/prefs_dialog.rs | 14 +++++++++++++- crates/ui-egui/src/prefs_dialog/tests.rs | 19 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 923459f898..13d5a00d8a 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -400,7 +400,8 @@ impl VectorcraftApp { crate::ime::install_hooks(ctx); self.adopt_context(ctx); if !self.styled { - theme::apply(ctx, self.ui.brightness); + // The saved accent, color scheme and canvas color, not the defaults (#4837). + prefs_dialog::reapply(self, ctx); egui_extras::install_image_loaders(ctx); self.styled = true; } diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index d638e0b261..b91c149f45 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -125,7 +125,7 @@ pub fn apply_runtime(app: &mut VectorcraftApp, ctx: &egui::Context) { scrub: p.scrub_numeric_fields, bare_points: p.numbers_without_units_are_points, }; - let id = egui::Id::unique("dc-applied-prefs"); + let id = applied_id(); let prev: Option = ctx.data(|d| d.get_temp::>(id)).flatten(); if prev != Some(want) { theme::apply_with(ctx, want.brightness, want.accent, want.scheme); @@ -160,6 +160,18 @@ pub fn apply_runtime(app: &mut VectorcraftApp, ctx: &egui::Context) { } } +/// Where [`apply_runtime`] keeps the preferences it last pushed into `ctx`. +fn applied_id() -> egui::Id { + egui::Id::unique("dc-applied-prefs") +} + +/// [`apply_runtime`] with nothing recorded as applied, so the theme (brightness, accent, color +/// scheme and canvas color) and the other UI-side preferences are pushed into `ctx` again. +pub(crate) fn reapply(app: &mut VectorcraftApp, ctx: &egui::Context) { + ctx.data_mut(|d| d.remove::>(applied_id())); + apply_runtime(app, ctx); +} + pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.dialog.clone() else { return }; let t = Tokens::get(ctx); diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index c433fbc029..80fb23a176 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -60,6 +60,25 @@ fn canvas_color_choices_paint_the_pasteboard() { assert!(app.run("prefs.set", serde_json::json!({"key": "canvasColor", "value": "pink"})).is_err()); } +/// #4837: the saved Color Scheme, Accent and Canvas Color are the ones the first frame shows, not +/// the defaults the first UI pass installs. +#[test] +fn saved_scheme_accent_and_canvas_color_show_from_the_first_frame() { + let mut app = app(); + for (key, value) in [("uiColorScheme", "classic"), ("uiAccent", "pink"), ("canvasColor", "white")] { + app.run("prefs.set", json!({"key": key, "value": value})).unwrap(); + } + let ctx = egui::Context::default(); + for _ in 0..2 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())); + out.textures_delta.clear(); + let (t, classic) = (Tokens::get(&ctx), Tokens::for_scheme(Scheme::Classic, app.ui.brightness, Accent::Pink)); + assert_eq!((t.accent, t.app_bar, t.panel), (classic.accent, classic.app_bar, classic.panel)); + assert_eq!(t.pasteboard, egui::Color32::WHITE); + assert_eq!(ctx.global_style().visuals.hyperlink_color, classic.accent); + } +} + fn app() -> VectorcraftApp { VectorcraftApp::new(Session::new(), Services::default()) } From f6b79c3f5084aa31c6bcfdf0ed521688126eb556 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 00:17:39 -0700 Subject: [PATCH 16/98] Docs: the type.underline line in docs/mcp.md no longer repeats the end of the sentence before it --- docs/mcp.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mcp.md b/docs/mcp.md index 2cd37deac9..6fc3dd98bf 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3199,7 +3199,7 @@ Pen's second anchor at (150, 431), in line with the square's centre. give its Kerning or Tracking field the keyboard focus with the value selected, or open its font family menu with the search field focused; each replies `{field: "kerning" | "tracking" | "fontName"}`. Without selected type or text the Type tool edits, the panel shows and the command fails. - shows it, in the same text; at a caret it leaves the text as it is. The UI command `type.underline` (Cmd+Shift+U, + The UI command `type.underline` (Cmd+Shift+U, in remote mode `press_key {key: "U", mods: {cmd: true, shift: true}}` or `invoke_menu`) does what the Character panel's Underline button does: it underlines the Type tool's selected text or the selected type objects, or removes the underline when the text the panel shows is underlined, and returns whether the text is now underlined. From ab2426ca15a5c99609140eec23a006e3541fd6da Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 23:53:09 -0700 Subject: [PATCH 17/98] Swatches: the delete prompt for several swatches uses each language's plural forms The confirmation for deleting several swatches and groups now goes through i18n::tn, as the Graphic Styles prompt does, and each catalog gives the count-dependent forms in an @plural row. --- crates/ui-egui/src/i18n/bg.tsv | 2 +- crates/ui-egui/src/i18n/bs.tsv | 2 +- crates/ui-egui/src/i18n/ca.tsv | 2 +- crates/ui-egui/src/i18n/cs.tsv | 2 +- crates/ui-egui/src/i18n/da.tsv | 2 +- crates/ui-egui/src/i18n/de.tsv | 2 +- crates/ui-egui/src/i18n/el.tsv | 2 +- crates/ui-egui/src/i18n/es.tsv | 2 +- crates/ui-egui/src/i18n/fi.tsv | 2 +- crates/ui-egui/src/i18n/fr.tsv | 2 +- crates/ui-egui/src/i18n/hu.tsv | 2 +- crates/ui-egui/src/i18n/id.tsv | 2 +- crates/ui-egui/src/i18n/it.tsv | 2 +- crates/ui-egui/src/i18n/ja.tsv | 2 +- crates/ui-egui/src/i18n/ka.tsv | 2 +- crates/ui-egui/src/i18n/ko.tsv | 2 +- crates/ui-egui/src/i18n/nl.tsv | 2 +- crates/ui-egui/src/i18n/pl.tsv | 2 +- crates/ui-egui/src/i18n/pt-br.tsv | 2 +- crates/ui-egui/src/i18n/ru.tsv | 2 +- crates/ui-egui/src/i18n/sv.tsv | 2 +- crates/ui-egui/src/i18n/tr.tsv | 2 +- crates/ui-egui/src/i18n/uk.tsv | 2 +- crates/ui-egui/src/i18n/vi.tsv | 2 +- crates/ui-egui/src/i18n/zh-hans.tsv | 2 +- crates/ui-egui/src/i18n/zh-hant.tsv | 2 +- crates/ui-egui/src/panels/swatches/actions.rs | 2 +- crates/ui-egui/src/panels/swatches/tests.rs | 20 +++++++++++++++++++ docs/target-app-parity.md | 1 + 29 files changed, 48 insertions(+), 27 deletions(-) diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f7687e1616..2ddd7fd2b6 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -732,7 +732,6 @@ font-technology Variable Променливи Delete the graphic style “{name}”? Изтриване на графичния стил „{name}“? Delete Theme Изтриване на тема Delete theme Изтриване на тема - Delete these {count} swatches and groups? Изтриване на тези {count} образца и групи? Delete these {n} graphic styles? Изтриване на тези {n} графични стила? Delete this saved preset Изтриване на тази запазена предварителна настройка Delete this {n} graphic style? Изтриване на този {n} графичен стил? @@ -3618,6 +3617,7 @@ imageTrace Outline Контур @plural {n} item|{n} items {n} обект|{n} обекта @plural Color Table ({n} color)|Color Table ({n} colors) Цветна таблица ({n} цвят)|Цветна таблица ({n} цвята) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? Изтриване на този графичен стил ({n})?|Изтриване на тези графични стилове ({n})? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Изтриване на този {n} образец или група?|Изтриване на тези {n} образца и групи? @plural {n} Layer|{n} Layers {n} слой|{n} слоя @plural {n} Object|{n} Objects {n} обект|{n} обекта @plural {n} area highlighted|{n} areas highlighted {n} осветена област|{n} осветени области diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index d32d915036..f356047ee3 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1917,7 +1917,6 @@ lighting Light Svjetlo Weight: Debljina: Art using a deleted global swatch keeps its colour. Crtež koji koristi izbrisani globalni uzorak boje zadržava svoju boju. Color Group: {name} Grupa boja: {name} - Delete these {count} swatches and groups? Izbrisati ove uzorke boja i grupe ({count})? Delete “{name}”? Izbrisati „{name}”? Document Swatches Uzorci boja dokumenta Edit or Apply Color Group Uredi ili primijeni grupu boja @@ -4088,6 +4087,7 @@ imageTrace Outline Obris @plural {n} item|{n} items {n} stavka|{n} stavke|{n} stavki @plural Color Table ({n} color)|Color Table ({n} colors) Tabela boja ({n} boja)|Tabela boja ({n} boje)|Tabela boja ({n} boja) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? Izbrisati ovaj {n} grafički stil?|Izbrisati ova {n} grafička stila?|Izbrisati ovih {n} grafičkih stilova? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Izbrisati {n} uzorak boje ili grupu?|Izbrisati {n} uzorka boja i grupe?|Izbrisati {n} uzoraka boja i grupa? @plural {n} Layer|{n} Layers {n} sloj|{n} sloja|{n} slojeva @plural {n} Object|{n} Objects {n} objekat|{n} objekta|{n} objekata @plural {n} area highlighted|{n} areas highlighted Istaknuto {n} područje|Istaknuta {n} područja|Istaknuto {n} područja diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index ae721b931e..34722c0b16 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1050,7 +1050,6 @@ Delete the library “{name}”? Voleu suprimir la biblioteca “{name}”? Delete Theme Suprimeix el tema Delete theme Suprimeix el tema - Delete these {count} swatches and groups? Voleu suprimir aquestes {count} mostres i grups? Delete these {n} graphic styles? Voleu suprimir aquests {n} estils gràfics? Delete this saved preset Suprimeix aquest preajust desat Delete this {n} graphic style? Voleu suprimir aquest {n} estil gràfic? @@ -4678,6 +4677,7 @@ @plural agrees with {n} default shortcut|agrees with {n} default shortcuts coincideix amb {n} drecera per defecte|coincideix amb {n} dreceres per defecte @plural Color Table ({n} color)|Color Table ({n} colors) Taula de colors ({n} color)|Taula de colors ({n} colors) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? Voleu suprimir aquest {n} estil gràfic?|Voleu suprimir aquests {n} estils gràfics? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Voleu suprimir aquesta {n} mostra o grup?|Voleu suprimir aquestes {n} mostres i grups? @plural {n} area highlighted|{n} areas highlighted {n} àrea ressaltada|{n} àrees ressaltades @plural {n} color|{n} colors {n} color|{n} colors @plural {n} commit|{n} commits {n} commit|{n} commits diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f21ad521cd..b5fbc21540 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1944,7 +1944,6 @@ lighting Light Světlo Weight: Tloušťka: Art using a deleted global swatch keeps its colour. Kresba, která používá odstraněnou globální položku vzorníku, si barvu ponechá. Color Group: {name} Skupina barev: {name} - Delete these {count} swatches and groups? Odstranit tyto položky vzorníku a skupiny ({count})? Delete “{name}”? Odstranit „{name}“? Document Swatches Vzorník dokumentu Edit or Apply Color Group Upravit nebo použít skupinu barev @@ -4122,6 +4121,7 @@ imageTrace Outline Obrys @plural {n} item|{n} items {n} položka|{n} položky|{n} položek @plural Color Table ({n} color)|Color Table ({n} colors) Tabulka barev ({n} barva)|Tabulka barev ({n} barvy)|Tabulka barev ({n} barev) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? Odstranit {n} grafický styl?|Odstranit {n} grafické styly?|Odstranit {n} grafických stylů? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Odstranit {n} položku vzorníku nebo skupinu?|Odstranit {n} položky vzorníku a skupiny?|Odstranit {n} položek vzorníku a skupin? @plural {n} Layer|{n} Layers {n} vrstva|{n} vrstvy|{n} vrstev @plural {n} Object|{n} Objects {n} objekt|{n} objekty|{n} objektů @plural {n} area highlighted|{n} areas highlighted Zvýrazněna {n} oblast|Zvýrazněny {n} oblasti|Zvýrazněno {n} oblastí diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index b310616e3a..4642b364bc 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1755,7 +1755,6 @@ lighting Light Lys Weight: Vægt: Art using a deleted global swatch keeps its colour. Grafik, der bruger en slettet global farveprøve, bevarer sin farve. Color Group: {name} Farvegruppe: {name} - Delete these {count} swatches and groups? Slet disse {count} farveprøver og grupper? Delete “{name}”? Slet »{name}«? Document Swatches Dokumentfarveprøver Edit or Apply Color Group Redigér eller anvend farvegruppe @@ -3854,6 +3853,7 @@ imageTrace Outline Kontur @plural {n} item|{n} items {n} element|{n} elementer @plural Color Table ({n} color)|Color Table ({n} colors) Farvetabel ({n} farve)|Farvetabel ({n} farver) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? Vil du slette denne {n} grafikstil?|Vil du slette disse {n} grafikstile? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Slet denne {n} farveprøve eller gruppe?|Slet disse {n} farveprøver og grupper? @plural {n} Layer|{n} Layers {n} lag|{n} lag @plural {n} Object|{n} Objects {n} objekt|{n} objekter @plural {n} area highlighted|{n} areas highlighted {n} område fremhævet|{n} områder fremhævet diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5b29c93e2d..320960d0ef 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -726,7 +726,6 @@ font-technology Variable Variabel Delete the graphic style “{name}”? Grafikstil „{name}“ löschen? Delete Theme Thema löschen Delete theme Thema löschen - Delete these {count} swatches and groups? Diese {count} Farbfelder und Gruppen löschen? Delete these {n} graphic styles? Diese {n} Grafikstile löschen? Delete this saved preset Diese gespeicherte Vorgabe löschen Delete this {n} graphic style? Diesen {n} Grafikstil löschen? @@ -3605,6 +3604,7 @@ imageTrace Outline Kontur @plural {n} item|{n} items {n} Element|{n} Elemente @plural Color Table ({n} color)|Color Table ({n} colors) Farbtabelle ({n} Farbe)|Farbtabelle ({n} Farben) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? Diesen {n} Grafikstil löschen?|Diese {n} Grafikstile löschen? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? {n} Farbfeld oder Gruppe löschen?|Diese {n} Farbfelder und Gruppen löschen? @plural {n} Layer|{n} Layers {n} Ebene|{n} Ebenen @plural {n} Object|{n} Objects {n} Objekt|{n} Objekte @plural {n} area highlighted|{n} areas highlighted {n} Bereich hervorgehoben|{n} Bereiche hervorgehoben diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 2909355306..7dfc4f3e67 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -706,7 +706,6 @@ font-technology Variable Μεταβλητές Delete the graphic style “{name}”? Να διαγραφεί το στυλ γραφικών «{name}»; Delete Theme Διαγραφή θέματος Delete theme Διαγραφή θέματος - Delete these {count} swatches and groups? Να διαγραφούν αυτά τα {count} δείγματα χρωμάτων και οι ομάδες; Delete these {n} graphic styles? Να διαγραφούν αυτά τα {n} στυλ γραφικών; Delete this saved preset Διαγραφή αυτής της αποθηκευμένης προεπιλογής Delete this {n} graphic style? Να διαγραφεί αυτό το {n} στυλ γραφικών; @@ -2017,6 +2016,7 @@ imageTrace Outline Περίγραμμα @plural {n} item|{n} items {n} στοιχείο|{n} στοιχεία @plural Color Table ({n} color)|Color Table ({n} colors) Πίνακας χρωμάτων ({n} χρώμα)|Πίνακας χρωμάτων ({n} χρώματα) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? Να διαγραφεί αυτό το {n} στυλ γραφικών;|Να διαγραφούν αυτά τα {n} στυλ γραφικών; +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Να διαγραφεί αυτό το {n} δείγμα χρώματος ή ομάδα;|Να διαγραφούν αυτά τα {n} δείγματα χρωμάτων και οι ομάδες; @plural {n} Layer|{n} Layers {n} επίπεδο|{n} επίπεδα @plural {n} Object|{n} Objects {n} αντικείμενο|{n} αντικείμενα @plural {n} area highlighted|{n} areas highlighted {n} περιοχή επισημάνθηκε|{n} περιοχές επισημάνθηκαν diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index bc21e2e6c4..454557e044 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -747,7 +747,6 @@ font-technology Variable Variables Delete the graphic style “{name}”? ¿Eliminar el estilo gráfico “{name}”? Delete Theme Eliminar tema Delete theme Eliminar tema - Delete these {count} swatches and groups? ¿Eliminar estas {count} muestras y grupos? Delete these {n} graphic styles? ¿Eliminar estos {n} estilos gráficos? Delete this saved preset Eliminar este preajuste guardado Delete this {n} graphic style? ¿Eliminar {n} estilo gráfico? @@ -3690,6 +3689,7 @@ imageTrace Outline Contorno @plural {n} item|{n} items {n} elemento|{n} elementos @plural Color Table ({n} color)|Color Table ({n} colors) Tabla de colores ({n} color)|Tabla de colores ({n} colores) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? ¿Eliminar {n} estilo gráfico?|¿Eliminar estos {n} estilos gráficos? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? ¿Eliminar {n} muestra o grupo?|¿Eliminar estas {n} muestras y grupos? @plural {n} Layer|{n} Layers {n} capa|{n} capas @plural {n} Object|{n} Objects {n} objeto|{n} objetos @plural {n} area highlighted|{n} areas highlighted {n} área resaltada|{n} áreas resaltadas diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 7941c911ba..ede17d2aca 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1906,7 +1906,6 @@ lighting Light Valo Weight: Paksuus: Art using a deleted global swatch keeps its colour. Poistettua globaalia värimallia käyttävä kuvitus säilyttää värinsä. Color Group: {name} Väriryhmä: {name} - Delete these {count} swatches and groups? Poistetaanko nämä {count} värimallia ja ryhmää? Delete “{name}”? Poistetaanko ”{name}”? Document Swatches Asiakirjan värimallit Edit or Apply Color Group Muokkaa tai käytä väriryhmää @@ -4040,6 +4039,7 @@ imageTrace Outline Ääriviiva @plural {n} item|{n} items {n} kohde|{n} kohdetta @plural Color Table ({n} color)|Color Table ({n} colors) Väritaulukko ({n} väri)|Väritaulukko ({n} väriä) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? Poistetaanko tämä {n} grafiikkatyyli?|Poistetaanko nämä {n} grafiikkatyyliä? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Poistetaanko tämä {n} värimalli tai ryhmä?|Poistetaanko nämä {n} värimallia ja ryhmää? @plural {n} Layer|{n} Layers {n} taso|{n} tasoa @plural {n} Object|{n} Objects {n} objekti|{n} objektia @plural {n} area highlighted|{n} areas highlighted {n} alue korostettu|{n} aluetta korostettu diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c59ae9ad42..e6c5ef0be5 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -749,7 +749,7 @@ font-technology Variable Variables Delete the graphic style “{name}”? Supprimer le style graphique « {name} » ? Delete Theme Supprimer le thème Delete theme Supprimer le thème - Delete these {count} swatches and groups? Supprimer ces {count} nuances et groupes ? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Supprimer {n} nuance ou groupe ?|Supprimer ces {n} nuances et groupes ? Delete these {n} graphic styles? Supprimer ces {n} styles graphiques ? Delete this saved preset Supprimer ce paramètre prédéfini enregistré Delete this {n} graphic style? Supprimer {n} style graphique ? diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3b1de01a5a..eb4e09e41a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -972,7 +972,6 @@ font-technology Variable Változó Delete the library “{name}”? Törlöd ezt a könyvtárat: „{name}”? Delete Theme Téma törlése Delete theme Téma törlése - Delete these {count} swatches and groups? {count} színminta és csoport törlése? Delete these {n} graphic styles? {n} grafikai stílus törlése? Delete this saved preset A mentett előbeállítás törlése Delete this {n} graphic style? {n} grafikai stílus törlése? @@ -4443,6 +4442,7 @@ theme Appearance Kinézet imageTrace Outline Körvonal @plural Color Table ({n} color)|Color Table ({n} colors) Színtábla ({n} szín) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? {n} grafikai stílus törlése? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? {n} színminta és csoport törlése? @plural {n} area highlighted|{n} areas highlighted Kiemelt terület: {n} @plural {n} color|{n} colors {n} szín @plural {n} fill|{n} fills {n} kitöltés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 9184b86356..e43dc998ae 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1755,7 +1755,6 @@ lighting Light Cahaya Weight: Ketebalan: Art using a deleted global swatch keeps its colour. Karya seni yang memakai swatch global terhapus tetap mempertahankan warnanya. Color Group: {name} Grup warna: {name} - Delete these {count} swatches and groups? Hapus {count} swatch dan grup ini? Delete “{name}”? Hapus “{name}”? Document Swatches Swatch dokumen Edit or Apply Color Group Edit atau terapkan grup warna @@ -3854,6 +3853,7 @@ imageTrace Outline Kontur @plural {n} item|{n} items {n} item @plural Color Table ({n} color)|Color Table ({n} colors) Tabel warna ({n} warna) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? Hapus {n} gaya grafis ini? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Hapus {n} swatch dan grup ini? @plural {n} Layer|{n} Layers {n} lapisan @plural {n} Object|{n} Objects {n} objek @plural {n} area highlighted|{n} areas highlighted {n} area disorot diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 605a4145bf..eefe97ac7f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -747,7 +747,6 @@ font-technology Variable Variabili Delete the graphic style “{name}”? Eliminare lo stile grafico “{name}”? Delete Theme Elimina tema Delete theme Elimina tema - Delete these {count} swatches and groups? Eliminare questi {count} campioni e gruppi? Delete these {n} graphic styles? Eliminare questi {n} stili grafici? Delete this saved preset Elimina questo preset salvato Delete this {n} graphic style? Eliminare {n} stile grafico? @@ -3726,6 +3725,7 @@ imageTrace Outline Contorno @plural {n} item|{n} items {n} elemento|{n} elementi @plural Color Table ({n} color)|Color Table ({n} colors) Tabella colori ({n} colore)|Tabella colori ({n} colori) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? Eliminare {n} stile grafico?|Eliminare questi {n} stili grafici? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Eliminare {n} campione o gruppo?|Eliminare questi {n} campioni e gruppi? @plural {n} Layer|{n} Layers {n} livello|{n} livelli @plural {n} Object|{n} Objects {n} oggetto|{n} oggetti @plural {n} area highlighted|{n} areas highlighted {n} area evidenziata|{n} aree evidenziate diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2542ac6636..51b72cd238 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1297,7 +1297,6 @@ font-technology Variable バリアブル Delete Workspace ワークスペースを削除 Delete the graphic style “{name}”? グラフィックスタイル「{name}」を削除しますか? Delete theme テーマを削除 - Delete these {count} swatches and groups? これら {count} 個のスウォッチとグループを削除しますか? Delete this saved preset この保存済みプリセットを削除 Delete {kind} Style {kind}スタイルを削除 Delete “{name}”? 「{name}」を削除しますか? @@ -3709,6 +3708,7 @@ file-filter Templates テンプレート imageTrace Outline アウトライン @plural Color Table ({n} color)|Color Table ({n} colors) カラーテーブル ({n} 色) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? {n} 個のグラフィックスタイルを削除しますか? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? これら {n} 個のスウォッチとグループを削除しますか? @plural {n} Layer|{n} Layers {n} レイヤー @plural {n} Object|{n} Objects {n} オブジェクト @plural {n} area highlighted|{n} areas highlighted {n} 個の範囲を強調表示 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index ab961a7108..7ae614f12b 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1658,7 +1658,6 @@ lighting Light სინათლე Weight: სისქე: Art using a deleted global swatch keeps its colour. წაშლილი გლობალური ფერის ნიმუშის მქონე ნახატი თავის ფერს ინარჩუნებს. Color Group: {name} ფერთა ჯგუფი: {name} - Delete these {count} swatches and groups? წაიშალოს ეს ფერის ნიმუშები და ჯგუფები ({count})? Delete “{name}”? წაიშალოს „{name}“? Document Swatches დოკუმენტის ფერის ნიმუშები Edit or Apply Color Group ფერთა ჯგუფის რედაქტირება ან გამოყენება @@ -3620,6 +3619,7 @@ font-technology Variable ცვლადი @plural {n} item|{n} items ელემენტი: {n} @plural Color Table ({n} color)|Color Table ({n} colors) ფერების ცხრილი (ფერი: {n}) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? წაიშალოს გრაფიკული სტილები ({n})? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? წაიშალოს ეს ფერის ნიმუშები და ჯგუფები ({n})? @plural {n} Layer|{n} Layers შრე: {n} @plural {n} Object|{n} Objects ობიექტი: {n} @plural {n} area highlighted|{n} areas highlighted გამოკვეთილი არე: {n} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index cbfe1c6f2a..23e55a28fd 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1288,7 +1288,6 @@ font-technology Variable 가변 Delete Workspace 작업 영역 삭제 Delete the graphic style “{name}”? 그래픽 스타일 “{name}”을(를) 삭제하시겠습니까? Delete theme 테마 삭제 - Delete these {count} swatches and groups? 견본과 그룹 {count}개를 삭제하시겠습니까? Delete this saved preset 저장된 이 프리셋 삭제 Delete {kind} Style {kind} 스타일 삭제 Delete “{name}”? “{name}”을(를) 삭제하시겠습니까? @@ -3698,6 +3697,7 @@ file-filter Templates 템플릿 imageTrace Outline 윤곽선 @plural Color Table ({n} color)|Color Table ({n} colors) 색상 표 ({n}색)|색상 표 ({n}색) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? 그래픽 스타일 {n}개를 삭제하시겠습니까?|그래픽 스타일 {n}개를 삭제하시겠습니까? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? 견본과 그룹 {n}개를 삭제하시겠습니까? @plural {n} Layer|{n} Layers 레이어 {n}개|레이어 {n}개 @plural {n} Object|{n} Objects 개체 {n}개|개체 {n}개 @plural {n} area highlighted|{n} areas highlighted 강조된 영역 {n}개|강조된 영역 {n}개 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 65a2d54408..c269832419 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1769,7 +1769,6 @@ lighting Light Licht Weight: Dikte: Art using a deleted global swatch keeps its colour. Artwork met een verwijderd globaal staal behoudt zijn kleur. Color Group: {name} Kleurgroep: {name} - Delete these {count} swatches and groups? Deze {count} stalen en groepen verwijderen? Delete “{name}”? „{name}” verwijderen? Document Swatches Documentstalen Edit or Apply Color Group Kleurgroep bewerken of toepassen @@ -3868,6 +3867,7 @@ imageTrace Outline Contour @plural {n} item|{n} items {n} item|{n} items @plural Color Table ({n} color)|Color Table ({n} colors) Kleurentabel ({n} kleur)|Kleurentabel ({n} kleuren) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? Deze {n} grafische stijl verwijderen?|Deze {n} grafische stijlen verwijderen? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Deze {n} staal of groep verwijderen?|Deze {n} stalen en groepen verwijderen? @plural {n} Layer|{n} Layers {n} laag|{n} lagen @plural {n} Object|{n} Objects {n} object|{n} objecten @plural {n} area highlighted|{n} areas highlighted {n} gebied gemarkeerd|{n} gebieden gemarkeerd diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 7eb02958a1..65cb40b345 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1864,7 +1864,6 @@ lighting Light Światło Weight: Grubość: Art using a deleted global swatch keeps its colour. Kompozycja używająca usuniętej próbki globalnej zachowa swój kolor. Color Group: {name} Grupa kolorów: {name} - Delete these {count} swatches and groups? Usunąć te próbki i grupy ({count})? Delete “{name}”? Usunąć „{name}”? Document Swatches Próbki dokumentu Edit or Apply Color Group Edytuj lub zastosuj grupę kolorów @@ -3948,6 +3947,7 @@ imageTrace Outline Kontur @plural {n} item|{n} items {n} element|{n} elementy|{n} elementów @plural Color Table ({n} color)|Color Table ({n} colors) Tablica kolorów ({n} kolor)|Tablica kolorów ({n} kolory)|Tablica kolorów ({n} kolorów) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? Usunąć {n} styl graficzny?|Usunąć {n} style graficzne?|Usunąć {n} stylów graficznych? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Usunąć {n} próbkę lub grupę?|Usunąć {n} próbki i grupy?|Usunąć {n} próbek i grup? @plural {n} Layer|{n} Layers {n} warstwa|{n} warstwy|{n} warstw @plural {n} Object|{n} Objects {n} obiekt|{n} obiekty|{n} obiektów @plural {n} area highlighted|{n} areas highlighted Wyróżniono {n} obszar|Wyróżniono {n} obszary|Wyróżniono {n} obszarów diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index bd4b760a56..8fd2b3ba5e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -663,7 +663,6 @@ Delete Workspace Excluir Espaço de Trabalho Delete the graphic style “{name}”? Excluir o estilo gráfico “{name}”? Delete theme Excluir tema - Delete these {count} swatches and groups? Excluir estas {count} amostras e grupos? Delete this saved preset Excluir este predefinido salvo Delete {kind} Style Excluir Estilo de {kind} Delete “{name}”? Excluir “{name}”? @@ -3028,6 +3027,7 @@ imageTrace Outline Contorno @plural {n} item|{n} items {n} item|{n} itens @plural Color Table ({n} color)|Color Table ({n} colors) Tabela de Cores ({n} cor)|Tabela de Cores ({n} cores) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? Excluir este {n} estilo gráfico?|Excluir estes {n} estilos gráficos? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Excluir esta {n} amostra ou grupo?|Excluir estas {n} amostras e grupos? @plural {n} Layer|{n} Layers {n} Camada|{n} Camadas @plural {n} Object|{n} Objects {n} Objeto|{n} Objetos @plural {n} area highlighted|{n} areas highlighted {n} área realçada|{n} áreas realçadas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c1e796308b..379cf8a93e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -745,7 +745,6 @@ font-technology Variable Вариативные Delete the graphic style “{name}”? Удалить графический стиль «{name}»? Delete Theme Удалить тему Delete theme Удалить тему - Delete these {count} swatches and groups? Удалить эти образцы и группы ({count})? Delete these {n} graphic styles? Удалить эти графические стили ({n})? Delete this saved preset Удалить этот сохранённый набор Delete this {n} graphic style? Удалить этот графический стиль ({n})? @@ -3722,6 +3721,7 @@ imageTrace Outline Контур @plural {n} item|{n} items {n} элемент|{n} элемента|{n} элементов @plural Color Table ({n} color)|Color Table ({n} colors) Цветовая таблица ({n} цвет)|Цветовая таблица ({n} цвета)|Цветовая таблица ({n} цветов) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? Удалить этот графический стиль ({n})?|Удалить эти графические стили ({n})?|Удалить эти графические стили ({n})? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Удалить {n} образец или группу?|Удалить {n} образца и группы?|Удалить {n} образцов и групп? @plural {n} Layer|{n} Layers {n} слой|{n} слоя|{n} слоёв @plural {n} Object|{n} Objects {n} объект|{n} объекта|{n} объектов @plural {n} area highlighted|{n} areas highlighted {n} область выделена|{n} области выделено|{n} областей выделено diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 3da858206d..df96962c15 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -734,7 +734,6 @@ font-technology Variable Variabla Delete the graphic style “{name}”? Radera grafikstilen ”{name}”? Delete Theme Radera tema Delete theme Radera tema - Delete these {count} swatches and groups? Radera dessa {count} färgrutor och grupper? Delete these {n} graphic styles? Radera dessa {n} grafikstilar? Delete this saved preset Radera den här sparade förinställningen Delete this {n} graphic style? Radera denna {n} grafikstil? @@ -3618,6 +3617,7 @@ imageTrace Outline Kontur @plural {n} item|{n} items {n} objekt|{n} objekt @plural Color Table ({n} color)|Color Table ({n} colors) Färgtabell ({n} färg)|Färgtabell ({n} färger) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? Radera {n} grafisk stil?|Radera de här {n} grafiska stilarna? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Radera {n} färgruta eller grupp?|Radera dessa {n} färgrutor och grupper? @plural {n} Layer|{n} Layers {n} lager|{n} lager @plural {n} Object|{n} Objects {n} objekt|{n} objekt @plural {n} area highlighted|{n} areas highlighted {n} område framhävt|{n} områden framhävda diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ab17bb0530..d099149f07 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -726,7 +726,6 @@ font-technology Variable Değişken Delete the graphic style “{name}”? “{name}” grafik stili silinsin mi? Delete Theme Temayı Sil Delete theme Temayı sil - Delete these {count} swatches and groups? Bu {count} renk örnekleri ve grupları silinsin mi? Delete these {n} graphic styles? Bu {n} grafik stilleri silinsin mi? Delete this saved preset Bu kayıtlı ön ayarı silin Delete this {n} graphic style? Bu {n} grafik stili silinsin mi? @@ -3512,6 +3511,7 @@ imageTrace Outline Kontur @plural {n} item|{n} items {n} öğe @plural Color Table ({n} color)|Color Table ({n} colors) Renk Tablosu ({n} renk) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? Bu {n} grafik stili silinsin mi? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Bu {n} renk örneği ve grup silinsin mi? @plural {n} Layer|{n} Layers {n} Katman @plural {n} Object|{n} Objects {n} Nesne @plural {n} area highlighted|{n} areas highlighted {n} alan vurgulandı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ebf801d5f6..632d9c7326 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -741,7 +741,6 @@ font-technology Variable Варіативні Delete the graphic style “{name}”? Видалити графічний стиль «{name}»? Delete Theme Видалити тему Delete theme Видалити тему - Delete these {count} swatches and groups? Видалити ці зразки та групи ({count})? Delete these {n} graphic styles? Видалити ці графічні стилі ({n})? Delete this saved preset Видалити цей збережений набір Delete this {n} graphic style? Видалити цей графічний стиль ({n})? @@ -3719,6 +3718,7 @@ imageTrace Outline Контур @plural {n} item|{n} items {n} елемент|{n} елементи|{n} елементів @plural Color Table ({n} color)|Color Table ({n} colors) Таблиця кольорів ({n} колір)|Таблиця кольорів ({n} кольори)|Таблиця кольорів ({n} кольорів) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? Видалити цей графічний стиль ({n})?|Видалити ці графічні стилі ({n})?|Видалити ці графічні стилі ({n})? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Видалити {n} зразок або групу?|Видалити {n} зразки та групи?|Видалити {n} зразків і груп? @plural {n} Layer|{n} Layers {n} шар|{n} шари|{n} шарів @plural {n} Object|{n} Objects {n} об’єкт|{n} об’єкти|{n} об’єктів @plural {n} area highlighted|{n} areas highlighted Підсвічено {n} область|Підсвічено {n} області|Підсвічено {n} областей diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d41165b25e..f0c495d4f9 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -868,7 +868,6 @@ font-technology Variable Biến thiên Delete the library “{name}”? Xóa thư viện “{name}”? Delete Theme Xóa bộ màu Delete theme Xóa bộ màu - Delete these {count} swatches and groups? Xóa {count} mẫu màu và nhóm này? Delete these {n} graphic styles? Xóa {n} kiểu đồ họa này? Delete this saved preset Xóa thiết lập sẵn đã lưu này Delete this {n} graphic style? Xóa {n} kiểu đồ họa này? @@ -4084,6 +4083,7 @@ gradient-kind Radial Hướng tâm imageTrace Outline Đường viền @plural Color Table ({n} color)|Color Table ({n} colors) Bảng màu ({n} màu) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? Xóa {n} kiểu đồ họa này? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Xóa {n} mẫu màu và nhóm này? @plural {n} area highlighted|{n} areas highlighted {n} vùng được tô sáng @plural {n} color|{n} colors {n} màu @plural {n} fill|{n} fills {n} tô diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0cf85b50dc..61defa0208 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -690,7 +690,6 @@ Delete the graphic style “{name}”? 要删除图形样式“{name}”吗? Delete Theme 删除主题 Delete theme 删除主题 - Delete these {count} swatches and groups? 要删除这 {count} 个色板和编组吗? Delete these {n} graphic styles? 要删除这 {n} 个图形样式吗? Delete this saved preset 删除此已存储的预设 Delete this {n} graphic style? 要删除这 {n} 个图形样式吗? @@ -3565,6 +3564,7 @@ imageTrace Outline 轮廓 @plural {n} item|{n} items {n} 个项目 @plural Color Table ({n} color)|Color Table ({n} colors) 颜色表 ({n} 色) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? 要删除这 {n} 个图形样式吗? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? 要删除这 {n} 个色板和编组吗? @plural {n} Layer|{n} Layers {n} 个图层 @plural {n} Object|{n} Objects {n} 个对象 @plural {n} area highlighted|{n} areas highlighted 已标示 {n} 个区域 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 47a38972c5..423e780b0d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -709,7 +709,6 @@ font-technology Variable 可變 Delete the graphic style “{name}”? 要刪除繪圖樣式「{name}」嗎? Delete Theme 刪除主題 Delete theme 刪除主題 - Delete these {count} swatches and groups? 要刪除這 {count} 個色票和群組嗎? Delete these {n} graphic styles? 要刪除這 {n} 個繪圖樣式嗎? Delete this saved preset 刪除此已儲存的預設集 Delete this {n} graphic style? 要刪除這 {n} 個繪圖樣式嗎? @@ -4542,6 +4541,7 @@ imageTrace Outline 輪廓 @plural {n} item|{n} items {n} 個項目 @plural Color Table ({n} color)|Color Table ({n} colors) 色彩表 ({n} 色) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? 要刪除這 {n} 個繪圖樣式嗎? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? 要刪除這 {n} 個色票和群組嗎? @plural {n} Layer|{n} Layers {n} 個圖層 @plural {n} Object|{n} Objects {n} 個物件 @plural {n} area highlighted|{n} areas highlighted 已標示 {n} 個區域 diff --git a/crates/ui-egui/src/panels/swatches/actions.rs b/crates/ui-egui/src/panels/swatches/actions.rs index 6e9a56fd9b..be275c108b 100644 --- a/crates/ui-egui/src/panels/swatches/actions.rs +++ b/crates/ui-egui/src/panels/swatches/actions.rs @@ -42,7 +42,7 @@ pub(super) fn delete(app: &mut VectorcraftApp, names: Vec, now: bool) { } let message = match names.as_slice() { [n] => crate::i18n::fmt(tl!("Delete “{name}”?"), &[("name", n)]), - _ => crate::i18n::fmt(tl!("Delete these {count} swatches and groups?"), &[("count", &names.len().to_string())]), + _ => crate::i18n::tn(names.len() as u64, "Delete this {n} swatch or group?", "Delete these {n} swatches and groups?"), }; crate::dialogs::confirm::ask(app, &message, tl!("Art using a deleted global swatch keeps its colour."), "swatch.delete", params); } diff --git a/crates/ui-egui/src/panels/swatches/tests.rs b/crates/ui-egui/src/panels/swatches/tests.rs index c659413703..9f6f036e5a 100644 --- a/crates/ui-egui/src/panels/swatches/tests.rs +++ b/crates/ui-egui/src/panels/swatches/tests.rs @@ -265,6 +265,26 @@ fn delete_asks_then_removes_the_selection_and_unlinks_art() { assert_eq!(deletable(&["[None]".into(), REGISTRATION.into(), "Grays".into()]), ["Grays"]); } +/// The prompt for several swatches and groups gives the form the language's plural rule picks for the count. +#[test] +fn deleting_several_swatches_uses_each_count_form() { + let mut app = app(); + let mut prompt = |code: &str, count: usize| { + crate::i18n::set_current(crate::i18n::Lang::from_code(code).unwrap()); + let names: Vec = (1..=count).map(|n| format!("Swatch {n}")).collect(); + delete(&mut app, names, false); + let message = app.ui.dialog.as_ref().map(|d| d.str("message")); + crate::dialogs::cancel(&mut app); + crate::i18n::set_current(crate::i18n::Lang::EN); + message + }; + assert_eq!(prompt("pl", 2), Some("Usunąć 2 próbki i grupy?".to_string())); + assert_eq!(prompt("pl", 5), Some("Usunąć 5 próbek i grup?".to_string())); + assert_eq!(prompt("uk", 21), Some("Видалити 21 зразок або групу?".to_string())); + assert_eq!(prompt("uk", 22), Some("Видалити 22 зразки та групи?".to_string())); + assert_eq!(prompt("uk", 25), Some("Видалити 25 зразків і груп?".to_string())); +} + #[test] fn new_swatch_dialog_prefills_the_active_colour_and_saves_into_the_selected_group() { let mut app = app(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a4207f302d..416501d553 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -912,6 +912,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged. - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. Editing, renaming, deleting or merging a global swatch also changes the colors linked to it in graph designs, the art of brushes and the tile that pattern editing's Cancel puts back, in the same undo step as the rest of the art; Default Swatches with replace unlinks them there too. + The prompt for deleting several swatches and groups uses the interface language's plural form for the count. Evidence: `deleting_several_swatches_uses_each_count_form` in `crates/ui-egui/src/panels/swatches/tests.rs`. - **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. From 541a6c33f0647c93bdb436ca64a3d925b78afe29 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 00:05:16 -0700 Subject: [PATCH 18/98] Swatches: the Ukrainian, Russian and Bosnian delete prompt for 21 or 31 items names swatches and groups In these languages a count ending in 1 (other than 11) takes the singular form, which read as one swatch or one group. That form now names swatches and groups with the count in parentheses. --- crates/ui-egui/src/i18n/bs.tsv | 2 +- crates/ui-egui/src/i18n/ru.tsv | 2 +- crates/ui-egui/src/i18n/uk.tsv | 2 +- crates/ui-egui/src/panels/swatches/tests.rs | 4 +++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index f356047ee3..1c0416e677 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4087,7 +4087,7 @@ imageTrace Outline Obris @plural {n} item|{n} items {n} stavka|{n} stavke|{n} stavki @plural Color Table ({n} color)|Color Table ({n} colors) Tabela boja ({n} boja)|Tabela boja ({n} boje)|Tabela boja ({n} boja) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? Izbrisati ovaj {n} grafički stil?|Izbrisati ova {n} grafička stila?|Izbrisati ovih {n} grafičkih stilova? -@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Izbrisati {n} uzorak boje ili grupu?|Izbrisati {n} uzorka boja i grupe?|Izbrisati {n} uzoraka boja i grupa? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Izbrisati uzorke boja i grupe ({n})?|Izbrisati {n} uzorka boja i grupe?|Izbrisati {n} uzoraka boja i grupa? @plural {n} Layer|{n} Layers {n} sloj|{n} sloja|{n} slojeva @plural {n} Object|{n} Objects {n} objekat|{n} objekta|{n} objekata @plural {n} area highlighted|{n} areas highlighted Istaknuto {n} područje|Istaknuta {n} područja|Istaknuto {n} područja diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 379cf8a93e..b605f92778 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3721,7 +3721,7 @@ imageTrace Outline Контур @plural {n} item|{n} items {n} элемент|{n} элемента|{n} элементов @plural Color Table ({n} color)|Color Table ({n} colors) Цветовая таблица ({n} цвет)|Цветовая таблица ({n} цвета)|Цветовая таблица ({n} цветов) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? Удалить этот графический стиль ({n})?|Удалить эти графические стили ({n})?|Удалить эти графические стили ({n})? -@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Удалить {n} образец или группу?|Удалить {n} образца и группы?|Удалить {n} образцов и групп? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Удалить образцы и группы ({n})?|Удалить {n} образца и группы?|Удалить {n} образцов и групп? @plural {n} Layer|{n} Layers {n} слой|{n} слоя|{n} слоёв @plural {n} Object|{n} Objects {n} объект|{n} объекта|{n} объектов @plural {n} area highlighted|{n} areas highlighted {n} область выделена|{n} области выделено|{n} областей выделено diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 632d9c7326..9454d2f364 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3718,7 +3718,7 @@ imageTrace Outline Контур @plural {n} item|{n} items {n} елемент|{n} елементи|{n} елементів @plural Color Table ({n} color)|Color Table ({n} colors) Таблиця кольорів ({n} колір)|Таблиця кольорів ({n} кольори)|Таблиця кольорів ({n} кольорів) @plural Delete this {n} graphic style?|Delete these {n} graphic styles? Видалити цей графічний стиль ({n})?|Видалити ці графічні стилі ({n})?|Видалити ці графічні стилі ({n})? -@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Видалити {n} зразок або групу?|Видалити {n} зразки та групи?|Видалити {n} зразків і груп? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Видалити зразки та групи ({n})?|Видалити {n} зразки та групи?|Видалити {n} зразків і груп? @plural {n} Layer|{n} Layers {n} шар|{n} шари|{n} шарів @plural {n} Object|{n} Objects {n} об’єкт|{n} об’єкти|{n} об’єктів @plural {n} area highlighted|{n} areas highlighted Підсвічено {n} область|Підсвічено {n} області|Підсвічено {n} областей diff --git a/crates/ui-egui/src/panels/swatches/tests.rs b/crates/ui-egui/src/panels/swatches/tests.rs index 9f6f036e5a..c649e282ba 100644 --- a/crates/ui-egui/src/panels/swatches/tests.rs +++ b/crates/ui-egui/src/panels/swatches/tests.rs @@ -280,7 +280,9 @@ fn deleting_several_swatches_uses_each_count_form() { }; assert_eq!(prompt("pl", 2), Some("Usunąć 2 próbki i grupy?".to_string())); assert_eq!(prompt("pl", 5), Some("Usunąć 5 próbek i grup?".to_string())); - assert_eq!(prompt("uk", 21), Some("Видалити 21 зразок або групу?".to_string())); + assert_eq!(prompt("uk", 21), Some("Видалити зразки та групи (21)?".to_string())); + assert_eq!(prompt("ru", 21), Some("Удалить образцы и группы (21)?".to_string())); + assert_eq!(prompt("bs", 21), Some("Izbrisati uzorke boja i grupe (21)?".to_string())); assert_eq!(prompt("uk", 22), Some("Видалити 22 зразки та групи?".to_string())); assert_eq!(prompt("uk", 25), Some("Видалити 25 зразків і груп?".to_string())); } From d0a903d5d7cae4ab055718e33d5d67ea6739f575 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 23:31:32 -0700 Subject: [PATCH 19/98] DXF export: ellipses and unevenly scaled circles are written as ELLIPSE A closed path of four cubics that is the four-cubic circle under an affine map (an ellipse, or a circle scaled out of proportion) is written as a native ELLIPSE in R13 and later, with its center, major axis and minor to major ratio. The check maps the path back onto the unit circle and runs the existing circle recognition on it. R12 keeps the existing fallback, and ellipses with a ratio under 1e-6 stay splines. The ratio and end parameter are written in full so that a thin ellipse keeps its size and a whole turn reads back closed. Part of #1299. --- crates/cad/src/scene.rs | 91 +++++++++++++++++++++++++- crates/cad/src/tests.rs | 127 +++++++++++++++++++++++++++++++++++-- docs/file-format-parity.md | 2 +- docs/gaps.md | 2 +- 4 files changed, 215 insertions(+), 7 deletions(-) diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index f75d437763..58e0350599 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -10,7 +10,7 @@ use kurbo::PathEl; use vectorcraft_color::Paint; use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, Node, NodeKind, StrokeLayer, SymbolBoxes, TextKind, TextObject, Unit}; use vectorcraft_effects::stroke; -use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2}; use crate::aci::nearest_aci; use crate::writer::{Codes, Drawing, EntityStyle, Handle, ImageDef, Layer, LineType, fixed}; @@ -184,6 +184,46 @@ fn circular_subpath(sub: &Sub) -> Option { Some(CircularSub { centre, radius, samples }) } +/// A closed subpath that is the four-cubic circle under an affine map: an ellipse, or a circle +/// scaled out of proportion. `u` and `v` are the conjugate semi-diameters through its first two +/// anchors. +struct EllipticalSub { + centre: Point, + u: Vec2, + v: Vec2, +} + +/// Fit a closed subpath of four cubics to one ellipse. Its anchors give the center and two +/// conjugate semi-diameters; the subpath mapped by their inverse must pass [`circular_subpath`] as +/// the unit circle, so the same tolerance and turn checks apply. +fn elliptical_subpath(sub: &Sub) -> Option { + if !sub.closed || sub.segs.len() != 4 { + return None; + } + let (a0, a1, a2) = (sub.start, sub.segs.first()?.end(), sub.segs.get(1)?.end()); + let centre = a0.midpoint(a2); + let (u, v) = (a0 - centre, a1 - centre); + let det = u.cross(v); + let scale2 = u.hypot2().max(v.hypot2()); + if !det.is_finite() || !scale2.is_finite() || det.abs() <= scale2 * 1e-9 { + return None; + } + let unit = |p: Point| { + let d = p - centre; + Point::new(d.cross(v) / det, u.cross(d) / det) + }; + let mut segs = Vec::with_capacity(4); + for seg in &sub.segs { + let Seg::Cubic(a, b, to) = *seg else { return None }; + segs.push(Seg::Cubic(unit(a), unit(b), unit(to))); + } + let circle = circular_subpath(&Sub { start: unit(sub.start), segs, closed: true })?; + if circle.centre.to_vec2().hypot() > 5e-4 || (circle.radius - 1.0).abs() > 5e-4 { + return None; + } + Some(EllipticalSub { centre, u, v }) +} + fn angle_deg(p: Point, centre: Point) -> f64 { (p.y - centre.y).atan2(p.x - centre.x).to_degrees().rem_euclid(360.0) } @@ -764,6 +804,8 @@ impl<'a> Scene<'a> { // Native circles/arcs are preferred by CAD/CAM software. In R12, however, a // stroked curve must stay on the flattened POLYLINE path so groups 40/41 retain // its width. Non-circular curves continue through the existing fallback. + } else if v.handles() && self.elliptical(&sub, style) { + // R13 and later write an ellipse as a native ELLIPSE. } else if v.handles() { self.spline(&sub, style); } else { @@ -901,6 +943,53 @@ impl<'a> Scene<'a> { true } + /// Write a recognized elliptical subpath as a native DXF ELLIPSE (R13 and later): its center, + /// the end of its major axis relative to the center, and the minor to major ratio. Returns + /// false when the curve is not an ellipse, leaving the caller to write it as a spline. + fn elliptical(&mut self, sub: &Sub, style: &EntityStyle) -> bool { + let Some(last) = sub.segs.last().map(|s| s.end()) else { return false }; + if !sub.closed || last.distance(sub.start) > 1e-9 { + return false; + } + let Some(ellipse) = elliptical_subpath(sub) else { return false }; + let centre = self.to_dxf * ellipse.centre; + let u = self.to_dxf * (ellipse.centre + ellipse.u) - centre; + let v = self.to_dxf * (ellipse.centre + ellipse.v) - centre; + // The semi-axes are the square roots of the eigenvalues of [[a, b], [b, d]] = u u^T + v v^T. + let (a, b, d) = (u.x * u.x + v.x * v.x, u.x * u.y + v.x * v.y, u.y * u.y + v.y * v.y); + let mean = (a + d) / 2.0; + let spread = ((a - d) / 2.0).hypot(b); + let (major, minor) = ((mean + spread).sqrt(), (mean - spread).max(0.0).sqrt()); + let angle = 0.5 * (2.0 * b).atan2(a - d); + let axis = Vec2::new(major * angle.cos(), major * angle.sin()); + let ratio = (minor / major).min(1.0); + // A thinner ellipse stays a spline: DXF readers such as ezdxf need a ratio of 1e-6 or more. + if !centre.x.is_finite() || !centre.y.is_finite() || !axis.x.is_finite() || !axis.y.is_finite() || !ratio.is_finite() || ratio < 1e-6 { + return false; + } + + // The drawing extents take the ellipse's bounding box: half-widths hypot(u.x, v.x) and + // hypot(u.y, v.y) about the center. + let (c, eu, ev) = (ellipse.centre, ellipse.u, ellipse.v); + let (hx, hy) = (eu.x.hypot(ev.x), eu.y.hypot(ev.y)); + for p in [Point::new(c.x - hx, c.y), Point::new(c.x + hx, c.y), Point::new(c.x, c.y - hy), Point::new(c.x, c.y + hy)] { + self.pt(p); + } + + let layer = self.layer.clone(); + let h = self.handle(); + self.e.entity("ELLIPSE", h, fixed::MODEL_RECORD, &layer, style, "AcDbEllipse"); + self.e.xyz(10, centre.x, centre.y); + self.e.xyz(11, axis.x, axis.y); + self.e.normal(); + // The ratio and the end parameter are written in full: rounded to six decimals, a thin + // ellipse's minor axis would change size, and a whole turn would read back as an open curve. + self.e.raw(40, &ratio.to_string()); + self.e.num(41, 0.0); + self.e.raw(42, &std::f64::consts::TAU.to_string()); + true + } + /// A curved subpath as one cubic B-spline whose clamped knots repeat at every joint, so it /// passes through every anchor (straight segments become straight cubics). fn spline(&mut self, sub: &Sub, style: &EntityStyle) { diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index d0abd13e84..3f5c73024a 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, CharStyle, Dash, Document, ImageBlob, ImageObject, Node, NodeKind, TextObject, Unit}; -use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes}; +use vectorcraft_geom::{Affine, ParamCurve, PathData, Point, Rect, Shape, shapes}; use crate::*; @@ -208,10 +208,129 @@ fn circles_and_circular_arcs_are_native_dxf_entities() { assert_eq!((all(&e[1], 10), all(&e[1], 20), all(&e[1], 40)), (vec![120.0], vec![50.0], vec![30.0]), "{version:?}"); assert_eq!((all(&e[1], 50), all(&e[1], 51)), (vec![0.0], vec![90.0]), "{version:?}"); } +} - // An actual ellipse is not close enough to a circle and remains a spline. - let ellipse = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]); - assert_eq!(kinds(&export(&ellipse, &opts(&ellipse)).unwrap()), ["SPLINE"]); +/// The path `p` with `xf` applied. +fn transformed(p: &PathData, xf: Affine) -> PathData { + PathData::from_bezpath(&(xf * p.to_bezpath())) +} + +fn close_to(a: &[f64], b: &[f64], tol: f64) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol) +} + +#[test] +fn ellipses_and_unevenly_scaled_circles_are_native_ellipses() { + let circle = shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0)); + let d = doc_with(|d| { + let stroke = || Paint::solid(Color::BLACK); + vec![ + // 80 × 40 pt about (60, 40): DXF center (60, 60), major axis (40, 0), ratio 0.5. + path(d, shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Paint::None, stroke(), 1.0), + // A 60 pt circle scaled 150% across about (50, 50): 90 × 60 pt, ratio 2/3. + path( + d, + transformed(&circle, Affine::translate((50.0, 50.0)) * Affine::scale_non_uniform(1.5, 1.0) * Affine::translate((-50.0, -50.0))), + Paint::None, + stroke(), + 1.0, + ), + // The first ellipse turned 30° about its center, filled: the outline is the ellipse too. + path( + d, + transformed(&shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Affine::rotate_about(30f64.to_radians(), Point::new(60.0, 40.0))), + Paint::solid(Color::BLACK), + Paint::None, + 0.0, + ), + ] + }); + for version in DxfVersion::ALL.into_iter().filter(|version| *version >= DxfVersion::R13) { + let out = export(&d, &DxfOptions { version, ..opts(&d) }).unwrap(); + let e = entities(&out); + assert_eq!(e.iter().map(|x| kind(x)).collect::>(), ["ELLIPSE", "ELLIPSE", "ELLIPSE", "HATCH"], "{version:?}"); + let ellipse = |x: &Pairs| (all(x, 10), all(x, 20), all(x, 11), all(x, 21), all(x, 40), all(x, 41), all(x, 42)); + let (cx, cy, mx, my, ratio, start, end) = ellipse(&e[0]); + assert_eq!((cx, cy, mx, my, ratio), (vec![60.0], vec![60.0], vec![40.0], vec![0.0], vec![0.5]), "{version:?}"); + assert!(close_to(&start, &[0.0], 1e-9) && close_to(&end, &[std::f64::consts::TAU], 1e-12), "{version:?}: a whole turn"); + let (cx, cy, mx, my, ratio, ..) = ellipse(&e[1]); + assert!(close_to(&[cx, cy, mx, my, ratio].concat(), &[50.0, 50.0, 45.0, 0.0, 2.0 / 3.0], 1e-6), "{version:?}: {:?}", e[1]); + // Turned 30° counterclockwise on screen, so clockwise with DXF's y up. + let (cx, cy, mx, my, ratio, ..) = ellipse(&e[2]); + let (cos, sin) = (30f64.to_radians().cos(), 30f64.to_radians().sin()); + assert!(close_to(&[cx, cy, mx, my, ratio].concat(), &[60.0, 60.0, 40.0 * cos, -40.0 * sin, 0.5], 1e-6), "{version:?}: {:?}", e[2]); + assert!(e[0].contains(&(100, "AcDbEllipse".into())), "{version:?}"); + } + // R12 has no ELLIPSE: the curves are flattened polylines. + let r12 = kinds(&export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap()); + assert!(!r12.iter().any(|k| k == "ELLIPSE") && r12.iter().any(|k| k == "POLYLINE"), "{r12:?}"); + // A thin ellipse keeps its ratio in full (six decimals would write 0.000002). + let thin = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(0.0, 50.0, 200.0, 50.0003)), Paint::None, Paint::solid(Color::BLACK), 1.0)]); + let e = entities(&export(&thin, &opts(&thin)).unwrap()); + assert_eq!(kind(&e[0]), "ELLIPSE"); + assert!(close_to(&all(&e[0], 40), &[1.5e-6], 1e-10), "{:?}", e[0]); +} + +#[test] +fn exported_ellipses_read_back_as_the_same_ellipse() { + let xf = Affine::rotate_about(30f64.to_radians(), Point::new(60.0, 40.0)); + let d = doc_with(|d| { + vec![path(d, transformed(&shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), xf), Paint::None, Paint::solid(Color::BLACK), 1.0)] + }); + let out = export(&d, &opts(&d)).unwrap(); + assert_eq!(kinds(&out), ["ELLIPSE"]); + let o = ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), center: false, ..ImportOptions::default() }; + let back = import(&out.bytes, &o).unwrap().document; + let mut paths = vec![]; + back.walk(|n| { + if let NodeKind::Path { path, .. } = &n.kind { + paths.push(path.to_bezpath()); + } + }); + assert_eq!(paths.len(), 1); + assert!(paths[0].elements().last().is_some_and(|el| matches!(el, kurbo::PathEl::ClosePath)), "the ellipse reads back closed"); + // Every point of the curve read back lies on the 80 × 40 pt ellipse turned 30°, once both are centered. + let bp = &paths[0]; + let found = bp.bounding_box().center(); + let inverse = xf.inverse(); + let original = Point::new(60.0, 40.0); + for seg in bp.segments() { + for i in 0..=16 { + let p = seg.eval(f64::from(i) / 16.0) - found.to_vec2() + original.to_vec2(); + let q = inverse * p; + let r = ((q.x - 60.0) / 40.0).hypot((q.y - 40.0) / 20.0); + assert!((r - 1.0).abs() < 1e-3, "{p:?} is off the ellipse ({r})"); + } + } +} + +#[test] +fn closed_four_cubic_shapes_that_are_not_writable_ellipses_stay_splines() { + // A rounded square: four cubics through the ellipse's anchors with longer handles. + let (c, r, k) = (Point::new(50.0, 50.0), 30.0, 30.0 * 0.8); + let mut squircle = vectorcraft_geom::BezPath::new(); + squircle.move_to(Point::new(c.x + r, c.y)); + squircle.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r)); + squircle.curve_to(Point::new(c.x - k, c.y - r), Point::new(c.x - r, c.y - k), Point::new(c.x - r, c.y)); + squircle.curve_to(Point::new(c.x - r, c.y + k), Point::new(c.x - k, c.y + r), Point::new(c.x, c.y + r)); + squircle.curve_to(Point::new(c.x + k, c.y + r), Point::new(c.x + r, c.y + k), Point::new(c.x + r, c.y)); + squircle.close_path(); + // An egg: one half wider than the other. + let egg = shapes::ellipse(Rect::new(110.0, 20.0, 190.0, 60.0)).to_bezpath(); + let mut els: Vec = egg.elements().to_vec(); + if let Some(kurbo::PathEl::CurveTo(a, b, _)) = els.get_mut(1) { + a.x -= 10.0; + b.x -= 10.0; + } + let d = doc_with(|d| { + vec![ + path(d, PathData::from_bezpath(&squircle), Paint::None, Paint::solid(Color::BLACK), 1.0), + path(d, PathData::from_bezpath(&kurbo::BezPath::from_vec(els)), Paint::None, Paint::solid(Color::BLACK), 1.0), + // An ellipse thinner than the smallest ratio DXF readers take (5e-7 against 1e-6). + path(d, shapes::ellipse(Rect::new(0.0, 90.0, 200.0, 90.0001)), Paint::None, Paint::solid(Color::BLACK), 1.0), + ] + }); + assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE", "SPLINE", "SPLINE"]); } #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index dc23c77b7e..ffab9f5859 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -98,7 +98,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | 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) | | 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). CNC preset and VCarve QA remain open | +| 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; closed four-cubic ellipses, and circles scaled out of proportion, export as ELLIPSE in R13–2018; 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). CNC preset and VCarve QA remain open | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | | PSD | open, place, export | yes (merged image, every mode and depth) | yes (layered, max editability) | placing as layers (Photoshop Import Options) missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | diff --git a/docs/gaps.md b/docs/gaps.md index e42ecaed02..0b31e2a484 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -254,7 +254,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done:** Numeric fields keep a text drag's start when the pointer gains focus and movement exceeds the click threshold (#1326, in part). A completed focus click selects the whole value; keyboard focus, double-click and numeric stepping remain regression-tested. Short movements still classified as clicks can select all; that case remains open. -- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback. CNC preset and VCarve QA remain open. 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`. +- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE. A closed four-cubic ellipse, or a circle scaled out of proportion, is written as a native ELLIPSE in R13 and later; R12 keeps the existing fallback. CNC preset and VCarve QA remain open. 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`. - **Done:** Simplify and Add Anchor Points clear stale component selections on rewritten paths; Simplify previews and reports original/new point counts (#1272). - **Done:** Object › Path › Simplify opens its slider at the Auto-Simplify level for the selection and previews that result, as the [manual-simplify page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html) describes; simplifying only a Direct Selection part of a path is done too (G1). From d318a775607dceb0ad5e8ed7c7d11fa5cf029996 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 30 Oct 2026 23:32:34 -0700 Subject: [PATCH 20/98] DXF export: Alter Paths for Appearance outlines only strokes a CAD line can't draw Alter Paths for Appearance wrote every stroke as its filled outline, so a 1 pt circle became two circles and a hatch, and an open arc became a closed outline and a hatch: two cut lines where a CAM program expects one. The option now outlines only the strokes a line can't draw: the ones Preserve Appearance outlines, strokes wider than the widest lineweight (2.11 mm, to the hundredth), and every stroke in R13 and R14, which have no lineweights. Other strokes stay CIRCLE, ARC, SPLINE and LWPOLYLINE entities with their lineweights; R12 keeps them as POLYLINEs with their width. A dashed stroke whose offset starts part way into its pattern is now outlined with Preserve Appearance and with Alter Paths: a linetype always starts at the start of its pattern, so the dashes were written in the wrong place. --- crates/cad/src/lib.rs | 7 ++- crates/cad/src/scene.rs | 35 ++++++++++++-- crates/cad/src/tests.rs | 71 +++++++++++++++++++++++++++-- crates/engine/src/cmd/fileio/dxf.rs | 4 +- docs/gaps.md | 1 + docs/mcp.md | 6 ++- 6 files changed, 112 insertions(+), 12 deletions(-) diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 4afe84e332..3fc4bcb3b0 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -233,7 +233,8 @@ impl RasterFormat { #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Preserve { /// Type becomes glyph outlines; strokes a plain CAD line can't draw (aligned inside or - /// outside, width profiles, arrowheads, fitted dashes) become their filled outlines; brush + /// outside, width profiles, arrowheads, fitted dashes, dashes offset into their pattern) + /// become their filled outlines; brush /// strokes become their brush art. #[default] Appearance, @@ -277,7 +278,9 @@ pub struct DxfOptions { pub colors: ColorDepth, pub raster: RasterFormat, pub preserve: Preserve, - /// Alter Paths for Appearance: every stroke is written as the filled area it paints. + /// Alter Paths for Appearance: a stroke a CAD line can't draw (one [`Preserve::Appearance`] + /// outlines, one wider than the widest lineweight, or any stroke in R13 and R14, which have + /// no lineweights) is written as the filled area it paints. pub alter_paths: bool, /// Type is written as glyph outlines (always with [`Preserve::Appearance`]). pub outline_text: bool, diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 58e0350599..a292d5a7f5 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -273,6 +273,17 @@ const MAX_ENTITIES: u32 = 10_000_000; const MAX_COORD: f64 = 1e9; const MAX_POINTS: usize = 4_000_000; +/// Do the dashes of `d` start at the start of their pattern, as a linetype's do? A pattern of +/// odd length repeats twice in its linetype. +fn starts_with_pattern(d: &Dash) -> bool { + if !d.is_dashed() || !d.offset.is_finite() { + return true; + } + let period = d.pattern.iter().sum::<f64>() * if d.pattern.len() % 2 == 1 { 2.0 } else { 1.0 }; + let phase = d.offset.rem_euclid(period); + phase.min(period - phase) <= period * 1e-9 +} + /// `bp` flattened into polygons (each closing implicitly), consecutive repeats dropped. fn polygons(bp: &BezPath) -> Vec<Vec<Point>> { // Far outside any drawing, a curve flattens to millions of points at this tolerance. @@ -652,8 +663,7 @@ impl<'a> Scene<'a> { /// The DXF lineweight nearest to `width` points (scaled with the drawing when asked). fn lineweight(&mut self, width: f64) -> i32 { - let scale = if self.o.scale_lineweights { self.o.scale } else { 1.0 }; - let hundredths = width * 2540.0 / 72.0 * scale; + let hundredths = self.hundredths(width); let max = LINEWEIGHTS[LINEWEIGHTS.len() - 1]; if hundredths > f64::from(max) + 5.0 { self.warn(WIDE_LINES); @@ -661,6 +671,21 @@ impl<'a> Scene<'a> { LINEWEIGHTS.into_iter().min_by(|a, b| (f64::from(*a) - hundredths).abs().total_cmp(&(f64::from(*b) - hundredths).abs())).unwrap_or(max) } + /// `width` points in hundredths of a millimetre, the unit of DXF lineweights (scaled with + /// the drawing when asked). + fn hundredths(&self, width: f64) -> f64 { + let scale = if self.o.scale_lineweights { self.o.scale } else { 1.0 }; + width * 2540.0 / 72.0 * scale + } + + /// Can a CAD line draw a plain stroke `width` points wide in this version? R12 polylines + /// carry their width, R13 and R14 have no lineweights, and from 2000 a lineweight holds up + /// to 2.11 mm, to the hundredth. + fn line_keeps_width(&self, width: f64) -> bool { + let v = self.o.version; + !v.handles() || (v.r2000() && self.hundredths(width).round() <= f64::from(LINEWEIGHTS[LINEWEIGHTS.len() - 1])) + } + /// A linetype for dash pattern `d` (one per distinct pattern). fn linetype(&mut self, d: &Dash) -> String { let mut lengths: Vec<f64> = d.pattern.iter().map(|v| v * self.k).collect(); @@ -713,7 +738,11 @@ impl<'a> Scene<'a> { } let Some(rgb) = self.paint_rgb(&st.paint) else { return }; let plain = stroke::is_plain(st); - if self.o.alter_paths || (appearance && !plain) { + // A linetype has no phase: dashes that start part way into their pattern need outlines. + let lined = plain && st.dash.as_ref().is_none_or(starts_with_pattern); + // Alter Paths for Appearance outlines a stroke only when a line can't draw it. + let altered = self.o.alter_paths && !(lined && self.line_keeps_width(st.width)); + if altered || (appearance && !lined) { let region = stroke::outline_region(&PathData::from_bezpath(bp), rule, st); let style = self.style(rgb, st.opacity, None, None); self.area(®ion.to_bezpath(), FillRule::NonZero, &style, true); diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 3f5c73024a..aff8489c44 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -611,14 +611,44 @@ fn dashes_become_linetypes_and_opacity_transparency() { #[test] fn alter_paths_writes_strokes_as_filled_outlines() { - let d = shapes_doc(); - let out = export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap(); - // Each fill and stroke: its outlines, then its hatch. let fill = ["LWPOLYLINE", "HATCH"]; let rect_stroke = ["LWPOLYLINE", "LWPOLYLINE", "HATCH"]; + // Strokes a lineweight draws stay lines: the rectangle, the circle and an open arc. + let mut d = shapes_doc(); + let c = Point::new(150.0, 95.0); + let k = 20.0 * shapes::KAPPA; + let mut arc = vectorcraft_geom::BezPath::new(); + arc.move_to(Point::new(c.x + 20.0, c.y)); + arc.curve_to(Point::new(c.x + 20.0, c.y - k), Point::new(c.x + k, c.y - 20.0), Point::new(c.x, c.y - 20.0)); + let arc = path(&mut d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0); + let layer = d.layers[0].id; + d.insert(Some(layer), usize::MAX, arc).unwrap(); + let out = export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap(); + assert_eq!(kinds(&out), [&fill[..], &["LWPOLYLINE", "CIRCLE", "ARC"]].concat()); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + // R12 polylines carry any width, so its strokes stay lines too. + let r12 = export(&d, &DxfOptions { alter_paths: true, version: DxfVersion::R12, ..opts(&d) }).unwrap(); + assert!(!kinds(&r12).iter().any(|k| k == "HATCH")); + assert!(entities(&r12).iter().any(|e| kind(e) == "POLYLINE" && get(e, 40) == Some("2"))); + // R13 and R14 have no lineweights: every stroke becomes its outline, then its hatch. + let d = shapes_doc(); + let out = export(&d, &DxfOptions { alter_paths: true, version: DxfVersion::R14, ..opts(&d) }).unwrap(); assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["CIRCLE", "CIRCLE", "HATCH"]].concat()); // The rectangle's stroke: an outer and an inner boundary. assert_eq!(get(&entities(&out)[4], 91), Some("2")); + // A stroke wider than the widest lineweight (2.11 mm) is outlined at its true width. + let wide = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 8.0)]); + let out = export(&wide, &DxfOptions { alter_paths: true, ..opts(&wide) }).unwrap(); + assert_eq!(kinds(&out), rect_stroke); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let lines = export(&wide, &opts(&wide)).unwrap(); + assert_eq!(kinds(&lines), ["LWPOLYLINE"]); + assert!(lines.warnings.iter().any(|w| w.contains("2.11 mm"))); + // To the hundredth of a millimetre: 6 pt (2.117 mm) is outlined, 5.98 pt (2.110 mm) is a line. + for (width, expected) in [(6.0, &rect_stroke[..]), (5.98, &["LWPOLYLINE"][..])] { + let d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), width)]); + assert_eq!(kinds(&export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap()), expected, "{width} pt"); + } // Preserve Appearance outlines only the strokes a line can't draw. let mut d = shapes_doc(); let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); @@ -629,6 +659,41 @@ fn alter_paths_writes_strokes_as_filled_outlines() { let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap(); assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"]); assert!(editable.warnings.iter().any(|w| w.contains("stroke alignment"))); + // With Maximize Editability, Alter Paths outlines that stroke and keeps the circle a line. + let altered = export(&d, &DxfOptions { preserve: Preserve::Editability, alter_paths: true, ..opts(&d) }).unwrap(); + assert_eq!(kinds(&altered), [&fill[..], &rect_stroke, &["CIRCLE"]].concat()); + assert!(altered.warnings.is_empty(), "{:?}", altered.warnings); +} + +#[test] +fn dashes_that_start_part_way_into_their_pattern_are_outlined() { + let dashed = |offset: f64| { + doc_with(|d| { + let mut n = path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0); + if let Some(st) = n.appearance.stroke_mut() { + st.dash = Some(Dash { pattern: vec![6.0, 3.0, 2.0], offset, align_corners: false }); + } + vec![n] + }) + }; + // A linetype starts at the start of its pattern: an offset of a whole period (the odd + // pattern twice) keeps the line, any other offset needs the dashes' outlines and a hatch. + for (offset, outlined) in [(0.0, false), (22.0, false), (4.0, true), (11.0, true)] { + let d = dashed(offset); + let k = kinds(&export(&d, &opts(&d)).unwrap()); + if outlined { + assert!(k.len() > 2 && k.last().is_some_and(|k| k == "HATCH"), "offset {offset}: {k:?}"); + } else { + assert_eq!(k, ["LWPOLYLINE"], "offset {offset}"); + } + let altered = kinds(&export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap()); + assert_eq!(altered, k, "offset {offset}, Alter Paths"); + } + // Maximize Editability keeps the line and its linetype. + let d = dashed(4.0); + let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap(); + assert_eq!(kinds(&editable), ["LWPOLYLINE"]); + assert_eq!(get(&entities(&editable)[0], 6), Some("DASHED1")); } fn text_doc(text: &str, size: f64) -> Document { diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index a569f1b4c8..4320cbb9ca 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export DXF", [], None, - "{path?, version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (every stroke as its filled outline), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}", + "{path?, version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}", has_doc, export_dxf )] @@ -64,7 +64,7 @@ pub(super) const OPTIONS: &[FormatOption] = &[ name: "alterPaths", ty: "boolean", default: "false", - description: "Alter Paths for Appearance: every stroke as its filled outline", + description: "Alter Paths for Appearance: strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14", }, FormatOption { name: "outlineText", ty: "boolean", default: "false", description: "type as glyph outlines (always with preserve: appearance)" }, FormatOption { name: "selectedOnly", ty: "boolean", default: "false", description: "only the selected objects, in their layers" }, diff --git a/docs/gaps.md b/docs/gaps.md index 0b31e2a484..1fb18246a5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -254,6 +254,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done:** Numeric fields keep a text drag's start when the pointer gains focus and movement exceeds the click threshold (#1326, in part). A completed focus click selects the whole value; keyboard focus, double-click and numeric stepping remain regression-tested. Short movements still classified as clicks can select all; that case remains open. +- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback. Alter Paths for Appearance outlines only the strokes a CAD line can't draw (aligned, profiled, offset dashes, wider than 2.11 mm, or in R13 and R14), so a 1 pt circle or arc stays a CIRCLE or ARC. CNC preset and VCarve QA remain open. 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`. - **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE. A closed four-cubic ellipse, or a circle scaled out of proportion, is written as a native ELLIPSE in R13 and later; R12 keeps the existing fallback. CNC preset and VCarve QA remain open. 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`. - **Done:** Simplify and Add Anchor Points clear stale component selections on rewritten paths; Simplify previews and reports original/new point counts (#1272). diff --git a/docs/mcp.md b/docs/mcp.md index 76f8648ac3..4a37804b6e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3841,8 +3841,10 @@ from the bottom-left corner of the first artboard (or `artboard`), in drawing un 1 mm = 1 unit, which sets `$INSUNITS`); `scaleLineweights` scales the lineweights with them. `colors` is `8`, `16` or `256` indexed colours, or `true` (default; true colour with the nearest index, DXF 2004 and later). `preserve: "appearance"` (default) writes type as glyph outlines and the strokes a CAD line can't draw (inside or outside, -width profiles, arrowheads) as filled outlines; `"editability"` keeps type as text and every stroke a line. -`alterPaths` writes every stroke as its filled outline, `outlineText` outlines type, `selectedOnly` writes only the +width profiles, arrowheads, dashes offset into their pattern) as filled outlines; `"editability"` keeps type as text +and every stroke a line. `alterPaths` writes as filled outlines those strokes (with `"editability"` too), strokes +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 From baaffd63e791c88c71f060121bdb9e9d0533624d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 30 Oct 2026 22:25:03 -0700 Subject: [PATCH 21/98] Blob Brush: Tool Options set the brush's Angle and Roundness Blob Brush Tool Options gain Angle (-180 to 180 degrees, 0 by default) and Roundness (0 to 100 percent, 100 by default), with a preview of the tip and three dabs; a drag in the preview square sets the Angle. The elliptical tip keeps its angle on the page along a stroke, a 0 percent tip is drawn 0.1 percent as wide as it is long, and Size set to Pressure still varies its size. path.blob takes angle and roundness, and both values are kept with the tool's options. While a non-round tip drags, the Blob Brush shows the outline of the area it covers with its length label, and an Alt drag that smooths shows the Smooth tool's thin line. --- crates/engine/src/cmd/draw2/commands.rs | 2 +- crates/engine/src/cmd/draw2/freehand.rs | 30 +++++++-- crates/engine/src/tests/draw2.rs | 68 ++++++++++++++++++++ crates/engine/src/tests/toolsettings.rs | 15 +++++ crates/tools/src/draw2.rs | 22 ++++++- crates/tools/src/draw2/gesture.rs | 42 ++++++++---- crates/tools/src/draw2/gesture/tests.rs | 47 ++++++++++++++ crates/tools/src/settings.rs | 2 +- crates/ui-egui/src/dialogs/freehand.rs | 54 +++++++++++++++- crates/ui-egui/src/dialogs/freehand/tests.rs | 25 ++++++- docs/control-protocol.md | 3 +- docs/mcp.md | 2 +- docs/target-app-parity.md | 5 +- 13 files changed, 286 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index d34a79afb4..6946463e3e 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -128,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> { "Blob Brush", [], None, - "{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Blob Brush Tool Options' Size set to Pressure: the brush is size − sizeVariation wide at the lightest pen pressure and size + sizeVariation at the heaviest, at least 0.1 pt; a stroke without pressure keeps the size), sizeVariation?: pt (0), fidelity?: pt (0.01–100; the brush follows the stroke fitted at this tolerance as path.freehand fits a Pencil stroke, and a higher value gives a smoother shape; without it the brush follows every point), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with the topmost touching same-color blob and the touching ones next to it in stacking order in its group or layer → {id}", + "{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Blob Brush Tool Options' Size set to Pressure: the brush is size − sizeVariation wide at the lightest pen pressure and size + sizeVariation at the heaviest, at least 0.1 pt; a stroke without pressure keeps the size), sizeVariation?: pt (0), angle?: ° (0, kept within −180–180: the tip's long axis counter-clockwise from horizontal, kept on the page along the stroke), roundness?: % (100, kept within 0–100: the tip's short axis in percent of its long one, 100 round; 0 is drawn as 0.1), fidelity?: pt (0.01–100; the brush follows the stroke fitted at this tolerance as path.freehand fits a Pencil stroke, and a higher value gives a smoother shape; without it the brush follows every point), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with the topmost touching same-color blob and the touching ones next to it in stacking order in its group or layer → {id}", has_doc, blob ), diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index 6267c8e2c9..56ff0f3a53 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -373,8 +373,22 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> { if !(size.is_finite() && size > 0.0) { return Err(bad(C, "size must be positive")); } - let pressure = size_pressure(p, &samples, C)?; + let angle = number_or(p, "angle", 0.0, C)?; + let roundness = number_or(p, "roundness", 100.0, C)?; + if !angle.is_finite() || !roundness.is_finite() { + return Err(bad(C, "angle and roundness must be finite")); + } + let angle = angle.clamp(-180.0, 180.0); + let roundness = roundness.clamp(0.0, 100.0); + let tip = vectorcraft_tools::draw2::blob_tip(angle, roundness); + let squash = tip.inverse(); + // Width profiles use distance along the spine, so measure pressure on the same squashed + // points that the outline reads for a non-round tip. + let mapped_samples: Vec<(Point, Option<f64>)> = + if roundness < 100.0 { samples.iter().map(|(pt, pressure)| (squash * *pt, *pressure)).collect() } else { samples.clone() }; + let pressure = size_pressure(p, &mapped_samples, C)?; let pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect(); + let mapped_pts: Vec<Point> = mapped_samples.into_iter().map(|(pt, _)| pt).collect(); // The widest the brush gets: the area merges look in. let reach = pressure.as_ref().map_or(size, |(_, v)| size + v); // With `fidelity` (pt), the brush follows the stroke fitted by [`fit_freehand`], as a Pencil @@ -384,9 +398,12 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> { None | Some(Value::Null) => None, Some(_) => Some(number_or(p, "fidelity", 0.0, C)?.clamp(vectorcraft_tools::draw2::MIN_FIDELITY, 100.0)), }; - let spine = |tol: Option<f64>| match tol { - Some(tol) => PathData::single(fit_freehand(&pts, tol, false)), - None => PathData::single(SubPath::polyline(&pts, false)), + let spine = |tol: Option<f64>| { + let path = match tol { + Some(tol) => PathData::single(fit_freehand(&pts, tol, false)), + None => PathData::single(SubPath::polyline(&pts, false)), + }; + if roundness < 100.0 { path.transformed(squash) } else { path } }; let region = match (&pressure, pts.len() > 1) { (Some((prof, variation)), true) => { @@ -400,15 +417,16 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> { } } // A click: one dab as wide as the pen pressed. - (Some((prof, variation)), false) => brush_region(&pts, vectorcraft_tools::draw2::pressure_size(size, *variation, prof.at(0.0))), + (Some((prof, variation)), false) => brush_region(&mapped_pts, vectorcraft_tools::draw2::pressure_size(size, *variation, prof.at(0.0))), (None, _) => { let fitted = fidelity .filter(|_| pts.len() > 1) .map(|tol| po::outline_stroke(&spine(Some(tol)), size, Cap::Round, Join::Round, 4.0)) .filter(|r| !r.is_empty()); - fitted.unwrap_or_else(|| brush_region(&pts, size)) + fitted.unwrap_or_else(|| brush_region(&mapped_pts, size)) } }; + let region = if roundness < 100.0 { region.transformed(tip) } else { region }; if region.is_empty() { return Err(EngineError::Other("empty brush stroke".into())); } diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 935ae78a91..47adaf47cd 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -38,6 +38,74 @@ fn paths(s: &Session) -> Vec<(NodeId, PathData)> { v } +#[test] +fn blob_tip_angle_changes_horizontal_extent_and_stays_page_oriented() { + let bounds = |points: serde_json::Value, angle: i32, roundness: i32| { + let mut s = session(); + s.execute("path.blob", &json!({"points": points, "size": 40, "angle": angle, "roundness": roundness, "merge": false})).unwrap(); + paths(&s)[0].1.control_bounds().unwrap() + }; + let flat = bounds(json!([[100, 200], [300, 200]]), 0, 25); + let tilted = bounds(json!([[100, 200], [300, 200]]), 45, 25); + assert!(flat.height() < 15.0, "flat tip height: {}", flat.height()); + assert!(tilted.height() > 25.0, "tilted tip height: {}", tilted.height()); + let vertical = bounds(json!([[200, 100], [200, 300]]), 45, 25); + assert!((vertical.width() - tilted.height()).abs() < 2.0); + let round = bounds(json!([[100, 200], [300, 200]]), 45, 100); + assert!((round.height() - 40.0).abs() < 2.0); +} + +#[test] +fn blob_pressure_peak_stays_at_the_sample_with_a_flat_tip() { + use kurbo::ParamCurveNearest; + use vectorcraft_geom::Point; + + let mut s = session(); + s.execute( + "path.blob", + &json!({"points": [[100, 100, 0.0], [200, 100, 1.0], [200, 200, 0.0]], + "size": 10, "sizeMode": "pressure", "sizeVariation": 8, + "roundness": 25, "merge": false}), + ) + .unwrap(); + let outline = &paths(&s)[0].1; + let near_peak = Point::new(200.0, 110.0); + let past_peak = Point::new(200.0, 140.0); + let distance = |p: Point| outline.to_bezpath().segments().map(|seg| seg.nearest(p, 0.01).distance_sq.sqrt()).fold(f64::INFINITY, f64::min); + assert!(distance(near_peak) > distance(past_peak), "pressure must narrow after the corner sample"); +} + +#[test] +fn blob_tip_zero_and_invalid_values_are_safe() { + let mut s = session(); + s.execute("path.blob", &json!({"points": [[100, 100], [300, 100]], "size": 40, "roundness": 0})).unwrap(); + let b = paths(&s)[0].1.control_bounds().unwrap(); + assert!(b.height().is_finite() && b.height() > 0.0); + for (key, value) in [("angle", json!("NaN")), ("roundness", json!("oops"))] { + let mut p = json!({"points": [[100, 100], [300, 100]]}); + p[key] = value; + assert!(s.execute("path.blob", &p).is_err(), "{key}: {p}"); + } + s.execute("path.blob", &json!({"points": [[100, 200], [300, 200]], "angle": 1e300, "roundness": -1e300, "merge": false})).unwrap(); + assert!(paths(&s)[1].1.control_bounds().unwrap().height().is_finite()); +} + +#[test] +fn blob_pressure_size_works_with_flat_tip() { + let mut s = session(); + s.execute( + "path.blob", + &json!({"points": [[100, 100, 0], [200, 100, 1]], "size": 40, "sizeMode": "pressure", "sizeVariation": 15, "roundness": 25, "merge": false}), + ) + .unwrap(); + let variable = paths(&s)[0].1.control_bounds().unwrap(); + let mut fixed = session(); + fixed.execute("path.blob", &json!({"points": [[100, 100], [200, 100]], "size": 40, "roundness": 25, "merge": false})).unwrap(); + let baseline = paths(&fixed)[0].1.control_bounds().unwrap(); + assert!(variable.height() > baseline.height(), "{} <= {}", variable.height(), baseline.height()); + assert!(variable.height() < 20.0, "flat tip height: {}", variable.height()); +} + fn path(s: &Session, id: NodeId) -> PathData { s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone() } diff --git a/crates/engine/src/tests/toolsettings.rs b/crates/engine/src/tests/toolsettings.rs index f23c19c2ff..d1224e05da 100644 --- a/crates/engine/src/tests/toolsettings.rs +++ b/crates/engine/src/tests/toolsettings.rs @@ -14,6 +14,21 @@ fn select(s: &mut Session, tool: &str) { s.select_tool(tool, ViewInfo::default()).unwrap(); } +#[test] +fn blob_tip_options_report_and_persist() { + let mut s = session(); + select(&mut s, "blobBrush"); + assert_eq!((s.tool_options()["angle"].as_f64(), s.tool_options()["roundness"].as_f64()), (Some(0.0), Some(100.0))); + s.set_tool_option("angle", &json!(45)); + s.set_tool_option("roundness", &json!(0)); + select(&mut s, "selection"); + let saved = serde_json::to_string(&s.prefs).unwrap(); + let mut next = session(); + next.apply_prefs(serde_json::from_str(&saved).unwrap()); + select(&mut next, "blobBrush"); + assert_eq!((next.tool_options()["angle"].as_f64(), next.tool_options()["roundness"].as_f64()), (Some(45.0), Some(0.0))); +} + #[test] fn options_survive_a_tool_switch() { let mut s = session(); diff --git a/crates/tools/src/draw2.rs b/crates/tools/src/draw2.rs index be2019a383..23e61e602c 100644 --- a/crates/tools/src/draw2.rs +++ b/crates/tools/src/draw2.rs @@ -234,10 +234,20 @@ pub fn polyline(pts: &[Point]) -> BezPath { /// whose long axis runs at `angle` degrees counter-clockwise on the page (y-down document space) /// and whose short axis is `roundness` percent of the long one (kept within 1–100). pub fn eraser_tip(angle: f64, roundness: f64) -> Affine { + ellipse_tip(angle, roundness.clamp(1.0, 100.0)) +} + +/// Map a round brush tip to a page-oriented ellipse. 0 percent Roundness is drawn as +/// 0.1 percent so its inverse is defined. +pub fn blob_tip(angle: f64, roundness: f64) -> Affine { + ellipse_tip(angle, roundness.clamp(0.1, 100.0)) +} + +fn ellipse_tip(angle: f64, roundness: f64) -> Affine { let t = angle.to_radians(); let u = Vec2::new(t.cos(), -t.sin()); let v = Vec2::new(-u.y, u.x); - let r = roundness.clamp(1.0, 100.0) / 100.0; + let r = roundness / 100.0; let (xx, xy, yy) = (u.x * u.x + r * v.x * v.x, u.x * u.y + r * v.x * v.y, u.y * u.y + r * v.y * v.y); Affine::new([xx, xy, xy, yy, 0.0, 0.0]) } @@ -246,7 +256,15 @@ pub fn eraser_tip(angle: f64, roundness: f64) -> Affine { /// `roundness`) covers along `pts`: a round stroke along the points squashed by the tip's map, /// stretched back. pub fn eraser_area(pts: &[Point], size: f64, angle: f64, roundness: f64) -> PathData { - let stretch = eraser_tip(angle, roundness); + swept_tip_area(pts, size, eraser_tip(angle, roundness)) +} + +/// The filled area covered by a Blob Brush's page-oriented elliptical tip. +pub fn blob_area(pts: &[Point], size: f64, angle: f64, roundness: f64) -> PathData { + swept_tip_area(pts, size, blob_tip(angle, roundness)) +} + +fn swept_tip_area(pts: &[Point], size: f64, stretch: Affine) -> PathData { let squash = stretch.inverse(); let mut round: Vec<Point> = pts.iter().map(|p| squash * *p).collect(); if let [p] = round[..] { diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 6036a06d95..045a3304ea 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -24,7 +24,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind}; use vectorcraft_geom::Point; -use super::{FEEDBACK, eraser_area, points_json, polyline}; +use super::{FEEDBACK, blob_area, eraser_area, points_json, polyline}; use crate::guides::DrawSnap; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; @@ -71,9 +71,9 @@ pub struct GestureTool { pub size_pressure: bool, /// Blob Brush and Eraser Tool Options' Size Variation, in points. pub size_variation: f64, - /// The angle of the Eraser tip's long axis, in degrees counter-clockwise from horizontal. + /// The angle of the Blob Brush or Eraser tip's long axis, in degrees counter-clockwise from horizontal. pub angle: f64, - /// The Eraser tip's short axis in percent of its long one (100: round). + /// The Blob Brush or Eraser tip's short axis in percent of its long one (100: round). pub roundness: f64, /// Fill new pencil/brush strokes. pub fill: bool, @@ -439,6 +439,8 @@ impl GestureTool { "sizeMode": if self.size_pressure { "pressure" } else { "fixed" }, "sizeVariation": self.size_variation, "fidelity": self.fidelity, + "angle": self.angle, + "roundness": self.roundness, "keepSelected": self.keep_selected, "mergeOnlySelection": self.merge_only_selection }), @@ -691,15 +693,27 @@ impl Tool for GestureTool { _ => 1.0, }; let color = if self.id == "eraser" { [0x80, 0x80, 0x80] } else { FEEDBACK }; - // A tip that is not round shows the outline of the area it covers. - // shortcut: at its Size even with Pressure; the release erases at the pen's widths. - if self.id == "eraser" && self.roundness < 100.0 { - let area = eraser_area(&self.points, self.size, self.angle, self.roundness); - o.push(Overlay::Path { path: area.to_bezpath(), color, width: 1.0, dashed: false }); - return o; - } - // With Size set to Pressure, each step of the drag shows as wide as the pen pressed there. - if let Some(pressures) = self.size_pressures() { + // A tip that is not round shows the outline of the area it covers, each step of the drag + // as wide as the pen pressed there with Size set to Pressure. + if matches!(self.id, "eraser" | "blobBrush") && self.roundness < 100.0 && !self.smoothing { + let area = |pts: &[Point], size| { + if self.id == "blobBrush" { + blob_area(pts, size, self.angle, self.roundness) + } else { + eraser_area(pts, size, self.angle, self.roundness) + } + }; + if let Some(pressures) = self.size_pressures() { + for (seg, p) in self.points.windows(2).zip(pressures.windows(2)) { + let pressure = f64::from(p[0] + p[1]) / 2.0; + let size = super::pressure_size(self.size, self.size_variation, pressure); + o.push(Overlay::Path { path: area(seg, size).to_bezpath(), color, width: 1.0, dashed: false }); + } + } else { + o.push(Overlay::Path { path: area(&self.points, self.size).to_bezpath(), color, width: 1.0, dashed: false }); + } + } else if let Some(pressures) = self.size_pressures() { + // With Size set to Pressure, each step of the drag shows as wide as the pen pressed there. for (seg, p) in self.points.windows(2).zip(pressures.windows(2)) { let pressure = f64::from(p[0] + p[1]) / 2.0; let width = super::pressure_size(self.size, self.size_variation, pressure) * cx.zoom; @@ -785,6 +799,8 @@ impl Tool for GestureTool { "size": self.size, "sizeMode": if self.size_pressure { "pressure" } else { "fixed" }, "sizeVariation": self.size_variation, + "angle": self.angle, + "roundness": self.roundness, "keepSelected": self.keep_selected, "mergeOnlySelection": self.merge_only_selection }) @@ -810,7 +826,7 @@ impl Tool for GestureTool { }, "sizeVariation" if self.varies_size() => self.size_variation = v.as_f64().unwrap_or(self.size_variation).clamp(0.0, 1000.0), "angle" => self.angle = v.as_f64().unwrap_or(self.angle).clamp(-180.0, 180.0), - "roundness" => self.roundness = v.as_f64().unwrap_or(self.roundness).clamp(1.0, 100.0), + "roundness" => self.roundness = v.as_f64().unwrap_or(self.roundness).clamp(if self.id == "blobBrush" { 0.0 } else { 1.0 }, 100.0), "fill" => self.fill = v.as_bool().unwrap_or(self.fill), "altSmooth" => self.alt_smooth = v.as_bool().unwrap_or(self.alt_smooth), "livePreview" if self.id == "pencil" => self.live_preview = v.as_bool().unwrap_or(self.live_preview), diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index 17e905156e..1d6cc8acef 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -1391,6 +1391,53 @@ fn the_eraser_sends_its_tip_and_shows_the_area_it_covers() { assert!((b.y0 - 140.0).abs() < 0.1 && (b.y1 - 160.0).abs() < 0.1, "{b:?}"); } +/// The Blob Brush's Angle and Roundness (Blob Brush Tool Options, 0° and 100% by default) go with +/// its stroke, kept within −180–180° and 0–100%; while it drags, a tip that is not round shows the +/// outline of the area it covers with the length label, and an Alt drag that smooths shows the +/// Smooth tool's thin line. +#[test] +fn the_blob_brush_sends_its_tip_and_shows_the_area_it_covers() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = ToolContext { measurement_labels: true, ..cx(&d, &s, &p) }; + let mut t = GestureTool::new("blobBrush"); + assert_eq!((t.options()["angle"].as_f64(), t.options()["roundness"].as_f64()), (Some(0.0), Some(100.0))); + t.set_option("size", &json!(20)); + for (angle, roundness, sent) in [(45.0, 25.0, (45.0, 25.0)), (400.0, -5.0, (180.0, 0.0)), (-400.0, 250.0, (-180.0, 100.0))] { + t.set_option("angle", &json!(angle)); + t.set_option("roundness", &json!(roundness)); + assert_eq!((t.options()["angle"].as_f64(), t.options()["roundness"].as_f64()), (Some(sent.0), Some(sent.1))); + let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default()); + assert!( + matches!(&acts[2][0], Action::Exec(c, v) if c == "path.blob" && v["size"] == 20.0 && v["angle"] == sent.0 && v["roundness"] == sent.1), + "{acts:?}" + ); + } + // A tip 20 pt long and 5 pt wide, standing upright: along a horizontal drag from x 50 to + // x 150 the outline reaches 2.5 pt either side of the drag and 10 pt above and below its ends. + t.set_option("angle", &json!(90)); + t.set_option("roundness", &json!(25)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); + let o = t.overlays(&cx); + let Some(Overlay::Path { path, width, .. }) = o.iter().find(|o| matches!(o, Overlay::Path { .. })) else { panic!("{o:?}") }; + assert_eq!(*width, 1.0); + let b = vectorcraft_geom::Shape::bounding_box(path); + assert!((b.x0 - 47.5).abs() < 0.1 && (b.x1 - 152.5).abs() < 0.1, "{b:?}"); + assert!((b.y0 - 140.0).abs() < 0.1 && (b.y1 - 160.0).abs() < 0.1, "{b:?}"); + assert!(o.iter().any(|o| matches!(o, Overlay::Measure { .. })), "{o:?}"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0)); + let alt = Mods { alt: true, ..Default::default() }; + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0).with_mods(alt)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0).with_mods(alt)); + let o = t.overlays(&cx); + let [Overlay::Path { path, width, .. }] = &o[..] else { panic!("{o:?}") }; + assert_eq!(*width, 1.0); + assert!(vectorcraft_geom::Shape::bounding_box(path).height() < 0.1, "{o:?}"); +} + #[test] fn smooth_needs_selection_and_brackets_resize() { let (d, _) = doc_with_rect(); diff --git a/crates/tools/src/settings.rs b/crates/tools/src/settings.rs index 328ca4a80b..97d2b327c3 100644 --- a/crates/tools/src/settings.rs +++ b/crates/tools/src/settings.rs @@ -42,7 +42,7 @@ const ROWS: &[Row] = &[ (&["blobBrush", "eraser"], None, &["size", "sizeMode", "sizeVariation"]), (&["blobBrush"], None, &["fidelity", "keepSelected", "mergeOnlySelection"]), // Eraser Tool Options' tip shape. - (&["eraser"], None, &["angle", "roundness"]), + (&["blobBrush", "eraser"], None, &["angle", "roundness"]), // Live Paint Selection Tool Options (`builder::LivePaintSelectionTool`). (&["livePaintSelection"], None, &["selectFills", "selectStrokes", "color", "width"]), (&["polygon"], None, &["sides"]), diff --git a/crates/ui-egui/src/dialogs/freehand.rs b/crates/ui-egui/src/dialogs/freehand.rs index 783e79093a..85d8630567 100644 --- a/crates/ui-egui/src/dialogs/freehand.rs +++ b/crates/ui-egui/src/dialogs/freehand.rs @@ -6,7 +6,7 @@ //! Fields: `tool`; `fidelity` (points; Pencil, Paintbrush, Smooth and Blob Brush), `fill` (Pencil and //! Paintbrush), `keepSelected` (Pencil, Paintbrush and Blob Brush), `mergeOnlySelection` (Blob //! Brush), `closeWithin` and `editWithin` (screen pixels, 0 turns it off; Pencil and Paintbrush), -//! `angle` (degrees) and `roundness` (percent; Eraser), `livePreview` (Pencil: the stroke shows +//! `angle` (degrees) and `roundness` (percent; Blob Brush and Eraser), `livePreview` (Pencil: the stroke shows //! with its fill and stroke while it is drawn, or as a thin line until the release), `size` //! (points; Blob Brush and Eraser), and `sizeMode` (`fixed` or `pressure`) and `sizeVariation` //! (points; Blob Brush and Eraser: with Pressure the tip is Size − Variation wide at the lightest @@ -206,7 +206,11 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { number(ui, d, ("angle", tl!("Angle:")), "°", 0, (-180.0, 180.0)); } if d.fields.contains_key("roundness") { - number(ui, d, ("roundness", tl!("Roundness:")), "%", 0, (1.0, 100.0)); + let min = if d.str("tool") == "blobBrush" { 0.0 } else { 1.0 }; + number(ui, d, ("roundness", tl!("Roundness:")), "%", 0, (min, 100.0)); + } + if d.str("tool") == "blobBrush" { + blob_tip_preview(ui, d); } if d.fields.contains_key("sizeMode") { blob_size(ui, d); @@ -245,6 +249,52 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } +/// Side of the Blob Brush tip preview's square, in points. +const TIP_PREVIEW: f32 = 64.0; + +/// The Blob Brush's tip preview: a square with the tip at its Angle and Roundness and a line along +/// its long axis (a drag in the square sets the Angle), and beside it three dabs of the tip, the +/// middle one darker. +fn blob_tip_preview(ui: &mut egui::Ui, d: &mut Dialog) { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(egui::vec2(CONTENT_W, TIP_PREVIEW), egui::Sense::hover()); + let square = egui::Rect::from_min_size(rect.min, egui::vec2(TIP_PREVIEW, TIP_PREVIEW)); + let response = ui.interact(square, ui.scope_id().with("freehand-tip-angle"), egui::Sense::drag()); + if response.dragged() + && let Some(p) = response.interact_pointer_pos() + { + let delta = p - square.center(); + if delta.length_sq() > 1.0 { + d.fields.insert("angle".into(), json!(f64::from((-delta.y).atan2(delta.x)).to_degrees().round())); + } + } + let painter = ui.painter_at(rect); + painter.rect_filled(square, 2.0, t.input); + painter.rect_stroke(square, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); + let a = d.f64("angle", 0.0).clamp(-180.0, 180.0).to_radians(); + // The long axis on screen (y down: a positive angle tilts it up to the right) and the short one. + let u = egui::vec2(a.cos() as f32, -a.sin() as f32); + let v = egui::vec2(-u.y, u.x); + let long = 18.0; + let short = long * (d.f64("roundness", 100.0).clamp(0.0, 100.0) as f32 / 100.0).max(0.01); + let dab = |center: egui::Pos2, color: egui::Color32| { + let points = (0..32) + .map(|i| { + let s = std::f32::consts::TAU * i as f32 / 32.0; + center + u * (long * s.cos()) + v * (short * s.sin()) + }) + .collect(); + painter.add(egui::Shape::convex_polygon(points, color, egui::Stroke::NONE)); + }; + let center = square.center(); + dab(center, t.text); + painter.line_segment([center, center + u * 25.0], egui::Stroke::new(1.0, t.accent)); + let strip = square.right() + 2.5 * long; + for (i, color) in [t.text_dim, t.text, t.text_dim].into_iter().enumerate() { + dab(egui::pos2(strip + i as f32 * 1.5 * long, center.y), color); + } +} + /// OK: set the tool's options. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> { let values: Map<String, Value> = KEYS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect(); diff --git a/crates/ui-egui/src/dialogs/freehand/tests.rs b/crates/ui-egui/src/dialogs/freehand/tests.rs index 6d316ef024..0136d25c51 100644 --- a/crates/ui-egui/src/dialogs/freehand/tests.rs +++ b/crates/ui-egui/src/dialogs/freehand/tests.rs @@ -7,6 +7,27 @@ fn app() -> VectorcraftApp { app } +#[test] +fn blob_tip_fields_confirm_and_cancel() { + let mut app = app(); + app.run("tool.options", json!({"tool": "blobBrush"})).unwrap(); + let text = painted(&mut app); + assert!(text.contains("Angle:") && text.contains("Roundness:"), "{text}"); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((d.f64("angle", -1.0), d.f64("roundness", -1.0)), (0.0, 100.0)); + d.fields.insert("angle".into(), json!(45)); + d.fields.insert("roundness".into(), json!(0)); + super::super::confirm(&mut app).unwrap(); + assert_eq!( + (app.session.tool_options_of("blobBrush")["angle"].as_f64(), app.session.tool_options_of("blobBrush")["roundness"].as_f64()), + (Some(45.0), Some(0.0)) + ); + app.run("tool.options", json!({"tool": "blobBrush"})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("roundness".into(), json!(75)); + app.ui.dialog = None; + assert_eq!(app.session.tool_options_of("blobBrush")["roundness"], json!(0.0)); +} + /// The labels the open dialog paints. fn painted(app: &mut VectorcraftApp) -> String { crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) @@ -118,8 +139,8 @@ fn each_tool_shows_its_options_and_ok_sets_them() { assert!(text.contains("Size:"), "{tool}: {text}"); assert_eq!(text.contains("Fidelity:"), blob, "{tool}: {text}"); assert_eq!((text.contains("Keep selected"), text.contains("Merge only with selection")), (blob, blob), "{tool}: {text}"); - // The Eraser also has Angle and Roundness. - assert_eq!((text.contains("Angle:"), text.contains("Roundness:")), (!blob, !blob), "{tool}: {text}"); + // Both tools have Angle and Roundness. + assert!(text.contains("Angle:") && text.contains("Roundness:"), "{tool}: {text}"); app.ui.dialog.as_mut().unwrap().fields.insert("size".into(), json!(24)); super::super::confirm(&mut app).unwrap(); assert_eq!(app.session.tool_options_of(tool)["size"].as_f64(), Some(24.0), "{tool}"); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index fc2e265b6d..2f2a86d26f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -814,7 +814,8 @@ Paintbrush, Blob Brush; on by default for the Pencil and Paintbrush and off for the path a stroke draws, continues or merges into is not selected afterwards), `mergeOnlySelection` (Blob Brush; off by default, and while it is on a stroke merges only with selected blobs), `closeWithin` and `editWithin` (screen pixels, 0 turns it off; Pencil, Paintbrush), `size` (pt; Blob Brush, Eraser), and `angle` -(°, −180 to 180; 0) and `roundness` (%, 1 to 100; 100) of the Eraser's tip (Eraser). `ui.dialog.confirm` runs +(°, −180 to 180; 0) and `roundness` (%, 0 to 100 for the Blob Brush, where 0 is drawn as 0.1, and 1 to 100 for +the Eraser; 100) of the tip (Blob Brush, Eraser); the tip keeps its angle on the page along a stroke. `ui.dialog.confirm` runs `tool.setOption {tool, values}`. `ui.pointer` events also take `holdMs` (0..60000): the pointer then holds still that long, button down, before the diff --git a/docs/mcp.md b/docs/mcp.md index 4a37804b6e..add4788c4a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2642,7 +2642,7 @@ naming the key; the document does not change and no undo step is recorded: `t`, `path.reshapeSegment` and `object.blend.spine.reshapeSegment`; `x`, `y` and `t` in `path.curvatureEdit`; `x` and `y` in `path.convertAnchor`; `t` in `path.insertAnchor`; `fidelity` and the `within` of `redraw` in `path.freehand` (and in `brush.freehand`, which runs it); `size`, `sizeVariation`, `angle` and `roundness` in `path.eraseRegion`; -`size`, `sizeVariation` and `fidelity` in `path.blob`; `width` in `path.eraseSegments`; +`size`, `sizeVariation`, `angle`, `roundness` and `fidelity` in `path.blob`; `width` in `path.eraseSegments`; `radius` and `fidelity` in `path.smoothRegion`; `tolerance` in `path.joinScrub`; `dx` and `dy` in `path.moveAnchors`; and `dx`, `dy`, `tol` and `radius` in `path.reshape`. A null is read as a key left out. `path.convertAnchor` refuses an `x` without a `y`, or a `y` without an `x`, with "x and y must be given together". `path.freehand`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9eb17cc09e..02a030972b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -379,10 +379,10 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Area | Weight | Done | Missing (main items) | One agent (h) | |---|---:|---:|---|---:| | Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Global Edit of similar groups, transform nuances (the rotated persistent bounding box is done) | 4–6 | -| Drawing tools | 7 | 85% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners' Relative rounding (Curvature point display improved, #1236 in part) | 8–13 | +| Drawing tools | 7 | 85% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding (Curvature point display improved, #1236 in part) | 8–13 | | Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Global Edit of similar groups, transform nuances (the rotated persistent bounding box is done; no-op anchor Align history regression-tested, #1180 follow-up) | 4–6 | | Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of similar groups, transform nuances (the rotated persistent bounding box is done) | 4–6 | -| Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners' Relative rounding | 7–12 | +| Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | | 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 | @@ -737,6 +737,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Shape Builder pieces on dense hatches (lines at exact 0.25 or 0.5 pt steps across another line) come back one per stretch between crossings, with no speck where the sweep cut a line's two passes a rounding error apart: the planar map contracts the stub between such cuts into one vertex, bounded by each cluster's extent so a run of tiny level segments keeps its length (`crates/pathops/src/planar.rs` tests `a_dense_hatch_at_exact_binary_steps_cuts_each_line_once_per_crossing`, `a_run_of_tiny_level_segments_is_not_contracted_as_stubs`, `a_line_crossed_where_its_two_passes_part_is_cut_once`). The planar map's edge-source grid stays linear in memory on any input: a segment is filed once per cell it crosses, and the grid holds at most a budget of cell references (a few dozen per item plus a few rows' worth), past which the remaining items go to a short scanned list and are still found, so thousands of coincident segments cannot make the index quadratic (`crates/pathops/src/planar.rs` test `rect_index_holds_a_bounded_number_of_references`). The Blob Brush Tool Options have Keep selected and Merge only with selection, both off by default: while Keep selected is off, the blob a stroke draws or merges into is not selected afterwards, and while Merge only with selection is on, a stroke merges only with selected blobs of its color that it touches (`path.blob` `keepSelected` and `mergeOnlySelection`). + The Blob Brush Tool Options also have Angle and Roundness, as the reference app's [Blob Brush options](https://web.archive.org/web/20261012042029/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/blob-brush-options-and-best-practices.html) list them: Angle from −180° to 180° (0° by default) and Roundness from 0% to 100% (100%, a round tip, by default). The elliptical tip keeps its angle on the page along a stroke, a 0% tip is drawn 0.1% as wide as it is long, and the dialog shows the tip and three dabs of it. The values are kept with the tool's options (`path.blob` `angle` and `roundness`). Not done: the Random and pen-driven variations of Angle and Roundness. The Blob Brush Tool Options also have Fidelity, as the reference app's [Blob Brush options](https://web.archive.org/web/20261012042029/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/blob-brush-options-and-best-practices.html) list it: the Pencil's logarithmic slider from Accurate (0.01 pt) to Smooth (20 pt) and its field (up to 100 pt), 1.5 pt by default and kept with the tool's options. The brush follows the stroke fitted at the Fidelity, as a Pencil stroke is fitted: a low value follows the drag closely, jitter included, and a higher one gives smoother curves (`path.blob` `fidelity`). A Blob Brush stroke merges with the topmost path of its fill color and no stroke that it touches, and with the other such paths it touches in the same group or layer that are next to that path in stacking order with no other kind of object between them; matching paths in other groups or layers are left alone, as the Blob Brush tool guidelines in [Paint with fills and strokes](https://web.archive.org/web/20241219085603/https://helpx.adobe.com/illustrator/using/painting-fills-strokes.html) describe (`path.blob`). - **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides on a hidden or locked layer can't be selected, moved or deleted, Duplicate copies a layer's guides and Merge Selected and Flatten Artwork keep them on the merged layer; guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. Not done: guides stacked among the art of their layer (they are listed above it), a guide row's own eye and lock. Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score. From d9fefde06188463d51d6b6278aa4f8605ad9f03e Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 31 Oct 2026 01:05:24 -0700 Subject: [PATCH 22/98] Appearance: Reduce to Basic Appearance keeps the selected fill or stroke row Reduce to Basic Appearance kept the topmost visible fill and stroke even when another fill or stroke row was selected in the Appearance panel. It now keeps the selected row in place of the topmost one of its kind, and in each other selected object the row equal to it. With ids, or with no selected row, it keeps the topmost ones as before. --- crates/engine/src/cmd/appearance.rs | 23 +++++++++++--- crates/engine/src/tests/appearance.rs | 44 +++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index de4885564c..df691490ff 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> { "Reduce to Basic Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} keep only the topmost visible fill and stroke, without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects", + "{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", has_doc, reduce_basic ), @@ -614,11 +614,26 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> { let ids = require_targets(s, p, "appearance.reduceToBasic")?; // When every object comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); + // The Appearance panel's active row (a row of the first selected object's stack, and in each + // other target the row equal to it there), when the command acts on that selection. + let active = match s.appearance_item() { + Some(index) + if p.get("ids").is_none() && p.get("id").is_none() && !targets_contents(p)? && s.doc()?.selection.subjects().first() == ids.first() => + { + items_per_target(&before, &ids, &[index], true) + } + _ => vec![Vec::new(); ids.len()], + }; super::overprint::edit_counted(s, "Reduce to Basic Appearance", |d| { - for id in &ids { + for (id, at) in ids.iter().zip(&active) { if let Some(n) = d.node_mut(*id) { - // The topmost visible fill and stroke stay, without their own transparency or effects. - let top = |fill: bool| n.appearance.items.iter().rev().find(|i| i.visible() && i.is_fill() == fill).cloned(); + // The active row stays when it is a visible fill or stroke; otherwise the topmost + // visible one of each kind does. Both lose their own transparency and effects. + let picked = at.first().and_then(|i| n.appearance.items.get(*i)).filter(|i| i.visible()).cloned(); + let top = |fill: bool| match &picked { + Some(i) if i.is_fill() == fill => Some(i.clone()), + _ => n.appearance.items.iter().rev().find(|i| i.visible() && i.is_fill() == fill).cloned(), + }; let (fill, stroke) = (top(true), top(false)); // Other objects always keep a fill row (None when there was none); a group only // keeps the rows it had. diff --git a/crates/engine/src/tests/appearance.rs b/crates/engine/src/tests/appearance.rs index 7d8cb2e0fb..4906228129 100644 --- a/crates/engine/src/tests/appearance.rs +++ b/crates/engine/src/tests/appearance.rs @@ -400,6 +400,50 @@ fn clear_and_reduce_to_basic() { assert_eq!(node(&s, id).opacity, 0.4); } +/// Reduce to Basic Appearance keeps the Appearance panel's active fill or stroke row instead of the +/// topmost one of its kind, in each selected object with the same stack. With `ids` it keeps the +/// topmost ones. +#[test] +fn reduce_to_basic_keeps_the_active_row() { + let mut s = session(); + let a = rect(&mut s, 50.0); + let b = rect(&mut s, 200.0); + let c = rect(&mut s, 300.0); + let stack = |s: &mut Session| { + run(s, "appearance.addFill", json!({})); + run(s, "appearance.addStroke", json!({})); // [Fill, Stroke, Fill, Stroke] + run(s, "appearance.setItem", json!({"index": 0, "color": "#00ff00"})); + run(s, "appearance.setItem", json!({"index": 2, "color": "#ff0000"})); + run(s, "appearance.setItem", json!({"index": 1, "weight": 7})); + }; + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + stack(&mut s); + // The lower, green fill row is active: both objects keep it and their top stroke. + run(&mut s, "appearance.setActiveItem", json!({"index": 0})); + run(&mut s, "appearance.reduceToBasic", json!({})); + for id in [a, b] { + let n = node(&s, id); + assert!(n.appearance.is_basic(), "{:?}", n.appearance); + assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#00ff00"); + assert_eq!(stroke(&n, 1).width, 1.0); + } + // The lower stroke row is active: it stays, with the top fill. + run(&mut s, "edit.undo", json!({})); + run(&mut s, "appearance.setActiveItem", json!({"index": 1})); + run(&mut s, "appearance.reduceToBasic", json!({})); + let n = node(&s, a); + assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000"); + assert_eq!(stroke(&n, 1).width, 7.0); + // Given ids, the topmost fill and stroke stay. + run(&mut s, "select.set", json!({"ids": [c.0]})); + stack(&mut s); + run(&mut s, "appearance.setActiveItem", json!({"index": 0})); + run(&mut s, "appearance.reduceToBasic", json!({"ids": [c.0]})); + let n = node(&s, c); + assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000"); + assert_eq!(stroke(&n, 1).width, 1.0); +} + /// How many steps there are to undo and to redo, and whether the document has unsaved changes. fn history(s: &Session) -> (usize, usize, bool) { let st = s.doc().unwrap(); From f677c60abae62a270986df7585f0c387a288af05 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 30 Oct 2026 18:49:05 -0700 Subject: [PATCH 23/98] Perspective Selection: Shift constrains a move to the plane's axes and diagonals Holding Shift while dragging objects with the Perspective Selection tool snaps the move, in the plane's own coordinates, to the nearest plane axis or 45 degree diagonal. This applies to plain moves and to Alt and Ctrl+Alt copies. The preview, the final move and the measurement label use the snapped point. Pressing or releasing Shift, pressing 5 and pressing 1 to 4 during the drag recompute the preview from the pointer; a move perpendicular to the plane is not constrained. --- crates/engine/src/tests/persp_select.rs | 67 +++++++++++++++ crates/engine/src/tooling.rs | 5 ++ crates/tools/src/distort/perspective.rs | 81 ++++++++++++++++++- crates/tools/src/distort/perspective/tools.rs | 53 ++++++++---- docs/target-app-parity.md | 1 + 5 files changed, 191 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/tests/persp_select.rs b/crates/engine/src/tests/persp_select.rs index 3b848d927d..a6f912b732 100644 --- a/crates/engine/src/tests/persp_select.rs +++ b/crates/engine/src/tests/persp_select.rs @@ -111,6 +111,73 @@ fn alt_drag_copies_and_the_copy_stays_attached() { assert!(s.doc().unwrap().doc.node(copy).is_none()); } +#[test] +fn shift_drag_moves_on_a_plane_axis() { + let (mut s, id) = persp_session(); + s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap(); + let before = plane_pts(&s, id, Plane::Right, 0.0); + let h = grid(&s).homography(Plane::Right).unwrap(); + let start = bounds_of(&before).center(); + let (from, to) = (h.apply(start).unwrap(), h.apply(start + Vec2::new(80.0, 20.0)).unwrap()); + let n = undo_len(&s); + drag(&mut s, &[from, to, to], Mods { shift: true, ..Default::default() }); + assert_eq!(undo_len(&s), n + 1); + for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) { + assert!((b - *a - Vec2::new(80.0, 0.0)).hypot() < 1e-6, "shift move: {a:?} → {b:?}"); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(plane_pts(&s, id, Plane::Right, 0.0), before); +} + +#[test] +fn shift_drag_keeps_its_displacement_after_a_plane_key() { + let (mut s, id) = persp_session(); + let v = ViewInfo::default(); + s.select_tool("perspectiveSelection", v).unwrap(); + let before = plane_pts(&s, id, Plane::Right, 0.0); + let h = grid(&s).homography(Plane::Right).unwrap(); + let start = bounds_of(&before).center(); + let (from, to) = (h.apply(start).unwrap(), h.apply(start + Vec2::new(80.0, 20.0)).unwrap()); + let shift = Mods { shift: true, ..Default::default() }; + s.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, to.x, to.y), v).unwrap(); + s.tool_key(ToolKey::Digit(2), shift, v).unwrap(); + assert_eq!(grid(&s).plane, Plane::Ground); + s.pointer(&PointerEvent::new(PointerKind::Up, to.x, to.y).with_mods(shift), v).unwrap(); + for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) { + assert!((b - *a - Vec2::new(80.0, 0.0)).hypot() < 1e-6, "plane key: {a:?} → {b:?}"); + } +} + +#[test] +fn cmd_alt_shift_drag_copies_on_a_plane_axis() { + let (mut s, id) = persp_session(); + let v = ViewInfo::default(); + s.select_tool("directSelection", v).unwrap(); + s.select_tool("perspectiveSelection", v).unwrap(); + let before = plane_pts(&s, id, Plane::Right, 0.0); + let count = s.doc().unwrap().doc.node_count(); + let h = grid(&s).homography(Plane::Right).unwrap(); + let start = bounds_of(&before).center(); + let (from, to) = (h.apply(start).unwrap(), h.apply(start + Vec2::new(80.0, 20.0)).unwrap()); + let n = undo_len(&s); + drag(&mut s, &[from, to, to], Mods { cmd: true, alt: true, shift: true, ..Default::default() }); + assert_eq!(undo_len(&s), n + 1); + assert_eq!(s.doc().unwrap().doc.node_count(), count + 1); + let copy = *s.doc().unwrap().selection.objects.first().unwrap(); + assert_ne!(copy, id); + assert_eq!(s.doc().unwrap().selection.objects, vec![copy]); + assert_eq!(plane_pts(&s, id, Plane::Right, 0.0), before); + assert_eq!(attached(&s, copy), Some((Plane::Right, 0.0))); + for (a, b) in before.iter().zip(plane_pts(&s, copy, Plane::Right, 0.0)) { + assert!((b - *a - Vec2::new(80.0, 0.0)).hypot() < 1e-6, "shift copy: {a:?} → {b:?}"); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node_count(), count); + assert_eq!(plane_pts(&s, id, Plane::Right, 0.0), before); + assert_eq!(s.doc().unwrap().selection.objects, vec![id]); +} + #[test] fn cmd_alt_drag_copies_on_the_perspective_plane() { let (mut s, id) = persp_session(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 8229979d02..90f4ff61ce 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -457,6 +457,11 @@ impl Session { // 1–4 pick the active perspective plane while the grid is shown. if let Some(plane) = self.plane_key(key) { self.execute("perspective.plane.set", &serde_json::json!({ "plane": plane.id() }))?; + if self.tool_id() == "perspectiveSelection" { + let acts = self.with_tool_cx(view, |t, cx| t.key(cx, key, mods)); + self.take_tool_panic()?; + return self.apply_actions(acts); + } return Ok(vec![]); } let acts = self.with_tool_cx(view, |t, cx| t.key(cx, key, mods)); diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index 9a9863e554..2542e6a1a5 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -1204,11 +1204,90 @@ mod tests { let Action::Preview(cmd, v) = &acts[0] else { panic!("{acts:?}") }; assert_eq!((cmd.as_str(), &v["perpendicular"], &v["to"]), ("perspective.move", &json!(true), &json!([mid.x + 20.0, mid.y]))); assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text.starts_with("dZ")))); - assert!(t.key(&c, ToolKey::Digit(3), Mods::default()).is_empty()); + assert!(matches!(t.key(&c, ToolKey::Digit(3), Mods::default()).as_slice(), [Action::Preview(cmd, _)] if cmd == "perspective.move")); assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, mid.x + 20.0, mid.y)), vec![Action::Commit]); assert!(!t.busy()); } + #[test] + fn selection_move_shift_snaps_in_each_plane() { + for plane in Plane::ALL { + let g = grid(2); + let h = g.homography(plane).unwrap(); + let from = h.apply(Point::new(50.0, 50.0)).unwrap(); + let mut d = Document::new(800.0, 600.0); + let layer = d.layers[0].id; + let id = d.alloc_id(); + d.insert( + Some(layer), + 0, + vectorcraft_doc::Node::path( + id, + vectorcraft_geom::shapes::rectangle(Rect::new(from.x - 20.0, from.y - 20.0, from.x + 20.0, from.y + 20.0)), + vectorcraft_doc::Appearance::default_art(), + ), + ) + .unwrap(); + g.store(&mut d); + set_attachment(d.node_mut(id).unwrap(), plane, 0.0); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = cx(&d, &s, &p); + let hi = h.inverse().unwrap(); + let start = hi.apply(from).unwrap(); + for (raw, expected) in [ + (Vec2::new(60.0, 12.0), Vec2::new(60.0, 0.0)), + (Vec2::new(12.0, 60.0), Vec2::new(0.0, 60.0)), + (Vec2::new(40.0, 50.0), Vec2::new(45.0, 45.0)), + ] { + let to = h.apply(start + raw).unwrap(); + let mut t = PerspectiveSelectionTool::default(); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, from.x, from.y)); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x, to.y).with_mods(Mods { shift: true, ..Default::default() })); + let Some(Action::Preview(cmd, v)) = acts.last() else { panic!("{acts:?}") }; + assert_eq!(cmd, "perspective.move"); + let endpoint = Point::new(v["to"][0].as_f64().unwrap(), v["to"][1].as_f64().unwrap()); + let delta = hi.apply(endpoint).unwrap() - start; + assert!((delta - expected).hypot() < 1e-6, "{plane:?}: {delta:?} != {expected:?}"); + } + } + } + + #[test] + fn selection_move_recomputes_shift_copy_and_perpendicular() { + let (mut d, id) = doc_with_rect(); + grid(2).store(&mut d); + set_attachment(d.node_mut(id).unwrap(), Plane::Right, 0.0); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = cx(&d, &s, &p); + let h = PerspectiveGrid::current(&d).homography(Plane::Right).unwrap(); + let from = d.node(id).unwrap().geometric_bounds().unwrap().center(); + let start = h.inverse().unwrap().apply(from).unwrap(); + let to = h.apply(start + Vec2::new(60.0, 12.0)).unwrap(); + let mut t = PerspectiveSelectionTool::default(); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, from.x, from.y)); + let plain = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x, to.y)); + let Some(Action::Preview(_, raw)) = plain.last() else { panic!("{plain:?}") }; + assert_eq!(raw["to"], json!([to.x, to.y])); + let shifted = + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x, to.y).with_mods(Mods { shift: true, alt: true, ..Default::default() })); + let Some(Action::Preview(_, snapped)) = shifted.last() else { panic!("{shifted:?}") }; + assert_eq!(snapped["copy"], json!(true)); + assert_ne!(snapped["to"], raw["to"]); + let perpendicular = t.key(&c, ToolKey::Digit(5), Mods { shift: true, alt: true, ..Default::default() }); + let Some(Action::Preview(_, depth)) = perpendicular.last() else { panic!("{perpendicular:?}") }; + assert_eq!(depth["to"], raw["to"]); + let in_plane = t.key(&c, ToolKey::Digit(5), Mods { shift: true, alt: true, ..Default::default() }); + let Some(Action::Preview(_, snapped_again)) = in_plane.last() else { panic!("{in_plane:?}") }; + assert_eq!(snapped_again["to"], snapped["to"]); + let released = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x, to.y)); + let Some(Action::Preview(_, raw_again)) = released.last() else { panic!("{released:?}") }; + assert_eq!(raw_again["to"], raw["to"]); + } + #[test] fn escape_cancels_a_perspective_selection_drag() { let (mut d, id) = doc_with_rect(); diff --git a/crates/tools/src/distort/perspective/tools.rs b/crates/tools/src/distort/perspective/tools.rs index 541e7a8f97..9e9454b304 100644 --- a/crates/tools/src/distort/perspective/tools.rs +++ b/crates/tools/src/distort/perspective/tools.rs @@ -119,17 +119,18 @@ enum SelDrag { #[default] Idle, /// Pressed on an object: moves `ids` once the pointer leaves the drag threshold. `perp`: along - /// the plane's normal (5 pressed while dragging); `copy`: Alt at the last event. - Move { ids: Vec<NodeId>, from: Point, last: Point, began: bool, perp: bool, copy: bool }, + /// the plane's normal (5 pressed while dragging); `copy` and `shift`: modifiers at the last event. + Move { ids: Vec<NodeId>, from: Point, last: Point, began: bool, perp: bool, copy: bool, shift: bool }, /// Dragging `handle` of the perspective bounding box `rect` (plane space, on `plane` at `depth`). Scale { ids: Vec<NodeId>, plane: Plane, depth: f64, rect: Rect, handle: BoxHandle }, /// Dragging a plane widget. Plane(PlaneDrag), } -/// The Perspective Selection tool: selects, moves (Alt copies; press 5 while dragging to move -/// perpendicular to the plane) and scales (bounding-box handles; Shift proportional, Alt from the -/// centre) objects within their perspective planes; the arrow keys nudge them in perspective. +/// The Perspective Selection tool: selects, moves (Shift constrains to plane-space 45° directions, +/// Alt copies; press 5 while dragging to move perpendicular to the plane) and scales (bounding-box +/// handles; Shift proportional, Alt from the center) objects within their perspective planes; the +/// arrow keys nudge them in perspective. #[derive(Default)] pub struct PerspectiveSelectionTool { drag: SelDrag, @@ -163,21 +164,36 @@ pub(super) fn resize_cursor(d: Vec2) -> Cursor { impl PerspectiveSelectionTool { /// The `perspective.move` preview for the move drag (and its measurement label). fn move_preview(&mut self, cx: &ToolContext) -> Option<Action> { - let SelDrag::Move { ids, from, last, perp, copy, .. } = &self.drag else { return None }; + let SelDrag::Move { ids, from, last, perp, copy, shift, .. } = &self.drag else { return None }; let g = PerspectiveGrid::current(cx.doc); - let mut v = json!({"ids": crate::json_ids(ids), "from": [from.x, from.y], "to": [last.x, last.y], "copy": copy, "perpendicular": perp}); + let at = ids.first().and_then(|id| g.attachment_of(cx.doc, *id)).or((g.plane != Plane::None).then_some((g.plane, g.offset(g.plane)))); + let to = if *shift && !*perp { + at.and_then(|(plane, depth)| { + let h = g.homography_at(plane, depth)?; + let hi = h.inverse()?; + let start = hi.apply(*from)?; + let delta = hi.apply(*last)? - start; + let angle = (delta.y.atan2(delta.x) / std::f64::consts::FRAC_PI_4).round() * std::f64::consts::FRAC_PI_4; + let direction = Vec2::new(angle.cos(), angle.sin()); + let distance = delta.x * direction.x + delta.y * direction.y; + h.apply(start + direction * distance).filter(|p| p.x.is_finite() && p.y.is_finite()) + }) + .unwrap_or(*last) + } else { + *last + }; + let mut v = json!({"ids": crate::json_ids(ids), "from": [from.x, from.y], "to": [to.x, to.y], "copy": copy, "perpendicular": perp}); if g.plane != Plane::None { v["plane"] = json!(g.plane.id()); } - let at = ids.first().and_then(|id| g.attachment_of(cx.doc, *id)).or((g.plane != Plane::None).then_some((g.plane, g.offset(g.plane)))); self.measure = at.and_then(|(pl, depth)| { let text = if *perp { - format!("dZ: {}", cx.len(g.depth_at(pl, depth, *from, *last)? - depth)) + format!("dZ: {}", cx.len(g.depth_at(pl, depth, *from, to)? - depth)) } else { - let d = g.plane_delta(pl, depth, *from, *last)?; + let d = g.plane_delta(pl, depth, *from, to)?; cx.offset_label(d.x, d.y) }; - Some((*last, text)) + Some((to, text)) }); Some(Action::Preview("perspective.move".into(), v)) } @@ -223,19 +239,19 @@ impl Tool for PerspectiveSelectionTool { { acts.push(Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))); } - self.drag = SelDrag::Move { ids, from: p, last: p, began: false, perp: false, copy: m.alt }; + self.drag = SelDrag::Move { ids, from: p, last: p, began: false, perp: false, copy: m.alt, shift: m.shift }; acts } (PointerKind::Drag, SelDrag::Move { ids, from, began, perp, .. }) => { if !began && p.distance(from) < cx.tol(3.0) { - self.drag = SelDrag::Move { ids, from, last: p, began, perp, copy: m.alt }; + self.drag = SelDrag::Move { ids, from, last: p, began, perp, copy: m.alt, shift: m.shift }; return vec![]; } let mut out = vec![]; if !began { out.push(Action::Begin(if m.alt { "Copy in Perspective" } else { "Move in Perspective" }.into())); } - self.drag = SelDrag::Move { ids, from, last: p, began: true, perp, copy: m.alt }; + self.drag = SelDrag::Move { ids, from, last: p, began: true, perp, copy: m.alt, shift: m.shift }; out.extend(self.move_preview(cx)); out } @@ -289,10 +305,17 @@ impl Tool for PerspectiveSelectionTool { _ => vec![Action::Cancel], }; } - if let (ToolKey::Digit(5), SelDrag::Move { perp, began, .. }) = (key, &mut self.drag) { + if let (ToolKey::Digit(5), SelDrag::Move { perp, began, shift, .. }) = (key, &mut self.drag) { *perp = !*perp; + *shift = mods.shift; return if *began { self.move_preview(cx).into_iter().collect() } else { vec![] }; } + if matches!(key, ToolKey::Digit(1..=4)) + && let SelDrag::Move { began: true, shift, .. } = &mut self.drag + { + *shift = mods.shift; + return self.move_preview(cx).into_iter().collect(); + } let (dx, dy) = match key { ToolKey::Left => (-1, 0), ToolKey::Right => (1, 0), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 02a030972b..51c0373344 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1104,6 +1104,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur An Alt-drag with the Selection, Direct Selection, Group Selection, Rotate, Scale, Reflect or Shear tool copies the selection on its first move, and the moves after it transform those copies; the previews of the Move, Rotate, Scale, Reflect and Shear dialogs with Copy and Preview on copy the selection once in the same way (#1429). 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 on a plane constrains the displacement to the nearest plane axis or 45° diagonal; releasing Shift resumes the raw pointer position, and 5 still moves perpendicular to the plane. 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. The Layers panel's Delete Selection, Duplicate Selection, Collect in New Layer, Merge Selected, Flatten Artwork, Release to Layers (Sequence) and row drags also take time in proportion to the size of the document, and so do Cut Artboard and Duplicate Artboard with their art and each move of an Alt-drag of one artboard with the Artboard tool while Move/Copy Artwork with Artboard is on (#1429). In Outline view the renderer draws paths and compound paths from the geometry it keeps for them between frames, and it culls the members of clip groups by the bounds it keeps for them. Layers with Preview off and the outlines of Image Trace objects are drawn the same way. From 9dcbe3688e38393a56d4f9a722bfd0643d436f6c Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 31 Oct 2026 00:14:07 -0700 Subject: [PATCH 24/98] Swatches: the French one-item delete prompt keeps the no-break space before ? --- crates/ui-egui/src/i18n/fr.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e6c5ef0be5..fae329cc5b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -749,7 +749,7 @@ font-technology Variable Variables Delete the graphic style “{name}”? Supprimer le style graphique « {name} » ? Delete Theme Supprimer le thème Delete theme Supprimer le thème -@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Supprimer {n} nuance ou groupe ?|Supprimer ces {n} nuances et groupes ? +@plural Delete this {n} swatch or group?|Delete these {n} swatches and groups? Supprimer {n} nuance ou groupe ?|Supprimer ces {n} nuances et groupes ? Delete these {n} graphic styles? Supprimer ces {n} styles graphiques ? Delete this saved preset Supprimer ce paramètre prédéfini enregistré Delete this {n} graphic style? Supprimer {n} style graphique ? From 9e8e091c8655f525bc3b96ae165baa96f6096640 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 31 Oct 2026 00:00:47 -0700 Subject: [PATCH 25/98] Find Font: Find leaves out hidden and locked text select.font (the Find Font dialog's Find button) selected every text object using the font, including hidden or locked text and text in a hidden or locked group or layer. It now selects only text that is visible and unlocked through every group and layer, and not on a locked artboard. Text on a visible, unlocked template layer is still found. A font used only by hidden or locked text selects nothing. --- crates/engine/src/cmd/fonts.rs | 63 +++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 0aaa8ba39c..6b134afb60 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> { "Select Text by Font", [], None, - "{family, style?} select the text objects using the font → {count}", + "{family, style?} select the text objects using the font that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard → {count}", has_doc, select_font ), @@ -454,16 +454,21 @@ fn select_font(s: &mut Session, p: &Value) -> Result<Value> { let style = str_param(p, "style").map(str::to_string); let ids = scope(s, false)?; let d = &s.doc()?.doc; + // Only text the Selection tool can reach: not hidden or locked, in a hidden or locked group + // or layer, or on a locked artboard. + let editable = d.editable(); let hits: Vec<NodeId> = ids .into_iter() .filter(|id| { - text(d, *id).is_some_and(|t| { - t.runs.iter().any(|r| { - r.style.font_family.eq_ignore_ascii_case(&family) && style.as_ref().is_none_or(|x| r.style.font_style.eq_ignore_ascii_case(x)) + editable(*id) + && text(d, *id).is_some_and(|t| { + t.runs.iter().any(|r| { + r.style.font_family.eq_ignore_ascii_case(&family) && style.as_ref().is_none_or(|x| r.style.font_style.eq_ignore_ascii_case(x)) + }) }) - }) }) .collect(); + drop(editable); let n = hits.len(); s.select(|_, sel| sel.set(hits))?; Ok(json!({ "count": n })) @@ -568,6 +573,54 @@ mod tests { assert!(s.execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Nope"}})).is_err()); let _ = a; } + + /// Select Text by Font leaves out hidden and locked text, and text inside a hidden or locked + /// group or layer. Text on a visible, unlocked template layer is selected. + #[test] + fn select_font_skips_hidden_and_locked_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let text = |s: &mut Session, y: i32, font: &str| { + s.execute("text.create", &json!({"x": 10, "y": y, "text": "Serif", "font": font})).unwrap()["id"].as_u64().unwrap() + }; + let mut ids = vec![]; + for y in [20, 40, 60, 80, 100] { + ids.push(text(&mut s, y, "Source Serif 4")); + } + // Inter is used only by hidden or locked text. + let only_hidden = text(&mut s, 120, "Inter"); + for (id, group, cmd) in [ + (ids[1], false, "object.hide"), + (ids[2], false, "object.lock"), + (ids[3], true, "object.hide"), + (ids[4], true, "object.lock"), + (only_hidden, false, "object.hide"), + ] { + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + if group { + s.execute("object.group", &json!({})).unwrap(); + } + s.execute(cmd, &json!({})).unwrap(); + } + let mut layer_text = |props: &[Value]| { + let layer = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap(); + let id = text(&mut s, 140, "Source Serif 4"); + for p in props { + let mut p = p.clone(); + p["id"] = json!(layer); + s.execute("layer.setProps", &p).unwrap(); + } + id + }; + layer_text(&[json!({"visible": false})]); + layer_text(&[json!({"locked": true})]); + // Template on locks the layer; unlocking it makes its text selectable again. + let template = layer_text(&[json!({"template": true}), json!({"locked": false})]); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 2); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(ids[0]), NodeId(template)]); + assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 0); + assert!(s.doc().unwrap().selection.objects.is_empty()); + } } #[cfg(test)] From 9571ae0a462b678c591304ec43cdce538b34b166 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 30 Oct 2026 23:48:53 -0700 Subject: [PATCH 26/98] Outline Stroke, Expand: hidden and locked objects stay hidden and locked The art that replaces an outlined or expanded path did not take the path's hidden, locked or Knockout Shape state. Outline Stroke on a hidden path, or Expand on a group with a hidden member, made the new art visible (a filled and stroked path became half visible: the outline shown, the fill copy hidden), and a locked member's art was unlocked. The replacement now takes the path's visibility, lock and Knockout Shape state, and the fill copies and clipping paths inside it are shown and unlocked, so showing or unlocking the new art shows or unlocks all of it. This covers Outline Stroke, and Expand's strokes, gradient fills and pattern fills. --- crates/engine/src/cmd/pathops.rs | 5 +++- crates/engine/src/tests/objexpand.rs | 29 +++++++++++++++++++++++ crates/engine/src/tests/outlinestroke.rs | 30 ++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index ae9670b55d..56a223e133 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -888,11 +888,13 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br } /// A plain copy of path `l` (new ids, no transparency, name or mask) painted with `items`. +/// The copy is shown and unlocked. pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec<AppearanceItem>) -> Node { let mut f = d.reid(l); f.appearance = Appearance { items, ..Default::default() }.into(); f.opacity = 1.0; f.blend = BlendMode::Normal; + (f.visible, f.locked, f.knockout_shape) = (true, false, false); f.name = None; f.extras_mut().mask = None; if let NodeKind::Path { live, .. } = &mut f.kind { @@ -903,7 +905,7 @@ pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec<AppearanceItem> /// What stands for object `l` once its appearance became `parts` (paint order): the one part (in a /// group when it has effects of its own) or a group of them, with `l`'s transparency, name, opacity -/// mask and effects. `None` without parts. +/// mask, effects, and hidden, locked and knockout-shape states. `None` without parts. pub(crate) fn assemble(d: &mut Document, l: &Node, mut parts: Vec<Node>) -> Option<Node> { let root = match parts.len() { 0 => return None, @@ -911,6 +913,7 @@ pub(crate) fn assemble(d: &mut Document, l: &Node, mut parts: Vec<Node>) -> Opti _ => Node::group(d.alloc_id(), parts.into_iter().map(Arc::new).collect()), }; let mut root = with_transparency(d, root, l.opacity, l.blend); + (root.visible, root.locked, root.knockout_shape) = (l.visible, l.locked, l.knockout_shape); root.name = l.name.clone(); root.extras_mut().mask = l.extras().mask.clone(); root.appearance.effects = l.appearance.effects.clone(); diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 2c9e4ef22b..59b8db8909 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -159,6 +159,35 @@ fn eight_strips_match_the_gradient_samples() { assert!(matches!(selected(&s).appearance.fill_paint(), Paint::Gradient(_))); } +#[test] +fn expanding_a_group_keeps_its_hidden_and_locked_members_hidden_and_locked() { + // A hidden stroked square with a gradient fill, a locked square with a pattern fill and a + // plain square, grouped. + let mut s = session(); + let pat = pattern_rect(&mut s, Rect::new(20.0, 100.0, 80.0, 160.0), false); + let grad = gradient_rect(&mut s, Rect::new(20.0, 20.0, 80.0, 80.0), white_black(), true); + let plain = NodeId(run(&mut s, "shape.rectangle", json!({"x": 100, "y": 20, "width": 40, "height": 40}))["id"].as_u64().unwrap()); + run(&mut s, "select.set", json!({"ids": [pat.0, grad.0, plain.0]})); + run(&mut s, "object.group", json!({})); + s.edit("t", |d, _| { + d.node_mut(grad).unwrap().visible = false; + d.node_mut(pat).unwrap().locked = true; + Ok(()) + }) + .unwrap(); + run(&mut s, "object.expand", json!({})); + let g = selected(&s); + let ch = g.children().unwrap(); + assert_eq!(ch.len(), 3); + assert!(!clip_parts(&ch[0]).1.is_empty() && ch[0].locked && ch[0].visible, "the pattern's tiles stay locked: {:?}", ch[0]); + assert!(!ch[1].visible && !ch[1].locked, "the gradient square's art stays hidden: {:?}", ch[1]); + assert!(ch[2].visible && !ch[2].locked); + // Showing or unlocking the new art shows or unlocks all of it. + for c in ch[..2].iter().flat_map(|c| c.children().unwrap().iter()) { + c.walk(&mut |n| assert!(n.visible && !n.locked, "{n:?}")); + } +} + #[test] fn a_radial_gradient_becomes_concentric_ellipses() { let mut s = session(); diff --git a/crates/engine/src/tests/outlinestroke.rs b/crates/engine/src/tests/outlinestroke.rs index 17a6b5a09f..fad35ad434 100644 --- a/crates/engine/src/tests/outlinestroke.rs +++ b/crates/engine/src/tests/outlinestroke.rs @@ -122,6 +122,36 @@ fn every_stroke_is_outlined_in_paint_order() { assert_eq!(d.layers[0].children().unwrap()[0].appearance.items.len(), 3); } +/// The art that stands for an outlined path keeps the path's hidden, locked and knockout-shape +/// states, so a hidden member of a selected group stays hidden. +#[test] +fn outlined_art_keeps_the_hidden_locked_and_knockout_shape_states() { + let mut s = session(); + // A filled and stroked square (a fill copy and an outline) and a stroked line (one outline). + let sq = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 100, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + let ln = line(&mut s, json!({"weight": 4})); + s.execute("select.set", &json!({"ids": [sq.0, ln.0]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.edit("t", |d, _| { + d.node_mut(sq).unwrap().visible = false; + let l = d.node_mut(ln).unwrap(); + l.locked = true; + l.knockout_shape = true; + Ok(()) + }) + .unwrap(); + let g = outline(&mut s); + let ch = g.children().unwrap(); + assert_eq!(ch.len(), 2); + let (sq, ln) = (&ch[0], &ch[1]); + assert!(!sq.visible && !sq.locked, "the square's art stays hidden: {sq:?}"); + assert_eq!(sq.children().map(|c| c.len()), Some(2), "a fill copy and the outline"); + assert!(sq.children().unwrap().iter().all(|c| c.visible && !c.locked), "showing it shows all of it: {sq:?}"); + assert!(ln.visible && ln.locked && ln.knockout_shape, "the line's outline stays locked: {ln:?}"); +} + /// The live effect on a stroke item outlines that stroke (its own weight), not the top stroke. #[test] fn the_live_effect_outlines_the_stroke_it_sits_on() { From 06e8d711ae9cfa92b907fe782d2bdeb6f1d9129b Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 30 Oct 2026 23:18:01 -0700 Subject: [PATCH 27/98] DXF export: an open path of one straight segment is written as a LINE An open path with a single straight segment was written as a two-vertex LWPOLYLINE (POLYLINE before R14). It is now a LINE entity from its start to its end point. A stroke with a weight in R12, R13 and R14 keeps its polyline, since a LINE has no width. Closed paths and paths with more than one segment are unchanged. Part of #1299. --- crates/cad/src/import/tests.rs | 28 ++++++++++++++++ crates/cad/src/scene.rs | 22 ++++++++++--- crates/cad/src/tests.rs | 39 +++++++++++++++++++++++ crates/engine/src/cmd/fileio/tests_dxf.rs | 2 +- docs/mcp.md | 3 +- docs/target-app-parity.md | 2 +- 6 files changed, 89 insertions(+), 7 deletions(-) diff --git a/crates/cad/src/import/tests.rs b/crates/cad/src/import/tests.rs index d0dde65c90..6bd3d1c106 100644 --- a/crates/cad/src/import/tests.rs +++ b/crates/cad/src/import/tests.rs @@ -1049,6 +1049,34 @@ fn drawings_the_export_writes_read_back() { } } +#[test] +fn a_lone_segment_reads_back_from_the_line_the_export_writes() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut b = kurbo::BezPath::new(); + b.move_to((20.0, 30.0)); + b.line_to((120.0, 70.0)); + let id = doc.alloc_id(); + let black = Appearance::basic(Paint::None, Paint::solid(Color::rgb8(0, 0, 0)), 1.0); + doc.insert(Some(layer), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&b), black)).unwrap(); + for version in crate::DxfVersion::ALL.into_iter().filter(|v| v.r2000()) { + let opts = crate::DxfOptions { version, unit: Unit::Points, region: doc.artboards[0].rect, ..crate::DxfOptions::default() }; + let out = crate::export(&doc, &opts).unwrap(); + assert!(std::str::from_utf8(&out.bytes).unwrap().contains("\nLINE\n"), "{version:?}"); + let back = open(&out.bytes, &points()); + let art = leaves(&back.document); + assert_eq!(art.len(), 1, "{version:?}"); + let path = art[0].path_data().unwrap(); + assert_eq!(path.subpaths.len(), 1, "{version:?}"); + assert!(!path.subpaths[0].closed, "{version:?}"); + let shift = Affine::translate((0.0, 100.0 - height(&back.document))); + let points = path_points(&art[0]); + assert_eq!(points.len(), 17, "{version:?}: one segment"); + let ends = [shift * points[0], shift * points[16]]; + assert!(ends[0].distance(Point::new(20.0, 30.0)) < 1e-6 && ends[1].distance(Point::new(120.0, 70.0)) < 1e-6, "{version:?}: {ends:?}"); + } +} + #[test] fn closed_control_point_splines_stay_closed_through_export_and_import() { // (explicitly closed, ends where it starts): coincident endpoints alone do not close a path. diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 7ab1d17f83..f75d437763 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -740,9 +740,9 @@ impl<'a> Scene<'a> { self.e.int(98, 0); } - /// Path `bp` as lines: polylines for straight subpaths (and for curves in R12, flattened), - /// cubic splines for curved ones. `width`: the stroke weight, which DXF before 2000 gives - /// polylines as a width. + /// Path `bp` as lines: a line for an open subpath of one straight segment, polylines for + /// other straight subpaths (and for curves in R12, flattened), cubic splines for curved ones. + /// `width`: the stroke weight, which DXF before 2000 gives polylines as a width. fn lines(&mut self, bp: &BezPath, style: &EntityStyle, width: f64) { let v = self.o.version; let width = (!v.r2000() && width > 0.0).then_some(width * self.k); @@ -755,7 +755,11 @@ impl<'a> Scene<'a> { if sub.closed && pts.len() > 2 && pts.last().is_some_and(|p| p.distance(sub.start) <= 1e-9) { pts.pop(); } - self.polyline(&pts, sub.closed, style, width); + // A LINE has no width, so a stroke before 2000 keeps its polyline width. + match (sub.closed, width, pts.as_slice()) { + (false, None, &[a, b]) => self.line(a, b, style), + _ => self.polyline(&pts, sub.closed, style, width), + } } else if (v.handles() || width.is_none()) && self.circular(&sub, style) { // Native circles/arcs are preferred by CAD/CAM software. In R12, however, a // stroked curve must stay on the flattened POLYLINE path so groups 40/41 retain @@ -778,6 +782,16 @@ impl<'a> Scene<'a> { } } + /// A line from `a` to `b` (document space). + fn line(&mut self, a: Point, b: Point, style: &EntityStyle) { + let (a, b) = (self.pt(a), self.pt(b)); + let layer = self.layer.clone(); + let h = self.handle(); + self.e.entity("LINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbLine"); + self.e.xyz(10, a.x, a.y); + self.e.xyz(11, b.x, b.y); + } + /// A polyline through `pts` (document space): lightweight from R14, else a polyline with a /// vertex entity per point. fn polyline(&mut self, pts: &[Point], closed: bool, style: &EntityStyle, width: Option<f64>) { diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 30a8d1a819..d0abd13e84 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -214,6 +214,45 @@ fn circles_and_circular_arcs_are_native_dxf_entities() { assert_eq!(kinds(&export(&ellipse, &opts(&ellipse)).unwrap()), ["SPLINE"]); } +#[test] +fn a_lone_straight_segment_is_a_line() { + let segment = |closed: bool, extra: Option<Point>| { + let mut b = vectorcraft_geom::BezPath::new(); + b.move_to(Point::new(20.0, 30.0)); + b.line_to(Point::new(120.0, 70.0)); + if let Some(p) = extra { + b.line_to(p); + } + if closed { + b.close_path(); + } + PathData::from_bezpath(&b) + }; + let d = doc_with(|d| vec![path(d, segment(false, None), Paint::None, Paint::solid(Color::BLACK), 1.0)]); + for version in DxfVersion::ALL.into_iter().filter(|v| v.r2000()) { + let e = entities(&export(&d, &DxfOptions { version, ..opts(&d) }).unwrap()); + assert_eq!(e.iter().map(|x| kind(x)).collect::<Vec<_>>(), ["LINE"], "{version:?}"); + assert_eq!(e[0].iter().rfind(|p| p.0 == 100).map(|p| p.1.as_str()), Some("AcDbLine"), "{version:?}"); + assert_eq!((all(&e[0], 10), all(&e[0], 20)), (vec![20.0], vec![70.0]), "{version:?}: y up from the artboard's bottom"); + assert_eq!((all(&e[0], 11), all(&e[0], 21)), (vec![120.0], vec![30.0]), "{version:?}"); + } + + // Without a width (R12 writes a fill as its outline), R12 writes a LINE too; a stroke weight + // before 2000 keeps its polyline width. + let filled = doc_with(|d| vec![path(d, segment(false, None), Paint::solid(Color::BLACK), Paint::None, 0.0)]); + let r12 = |d: &Document| kinds(&export(d, &DxfOptions { version: DxfVersion::R12, ..opts(d) }).unwrap()); + assert_eq!(r12(&filled), ["LINE"]); + assert_eq!(r12(&d), ["POLYLINE", "VERTEX", "VERTEX", "SEQEND"]); + let r14 = entities(&export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap()); + assert_eq!((kind(&r14[0]), get(&r14[0], 43)), ("LWPOLYLINE", Some("1"))); + + // A closed segment and two segments stay polylines. + for p in [segment(true, None), segment(false, Some(Point::new(150.0, 20.0)))] { + let d = doc_with(|d| vec![path(d, p, Paint::None, Paint::solid(Color::BLACK), 1.0)]); + assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["LWPOLYLINE"]); + } +} + #[test] fn r12_stroked_circles_keep_polyline_width() { let d = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0)), Paint::None, Paint::solid(Color::BLACK), 3.0)]); diff --git a/crates/engine/src/cmd/fileio/tests_dxf.rs b/crates/engine/src/cmd/fileio/tests_dxf.rs index eec800d74a..298adeb111 100644 --- a/crates/engine/src/cmd/fileio/tests_dxf.rs +++ b/crates/engine/src/cmd/fileio/tests_dxf.rs @@ -93,7 +93,7 @@ fn selected_only_exports_the_selection_in_its_layers() { let line = s.execute("shape.line", &json!({"x1": 0, "y1": 90, "x2": 90, "y2": 90})).unwrap()["id"].as_u64().unwrap(); s.execute("select.set", &json!({"ids": [line]})).unwrap(); let dxf = text(&s.execute("document.export", &json!({"format": "dxf", "selectedOnly": true})).unwrap()); - assert_eq!(entities(&dxf), ["LWPOLYLINE"]); + assert_eq!(entities(&dxf), ["LINE"]); assert!(dxf.contains("\n 2\nTop\n") && !dxf.contains("\n 2\nLayer 1\n"), "only the selection's layer"); s.execute("select.none", &json!({})).unwrap(); let e = s.execute("document.export", &json!({"format": "dxf", "selectedOnly": true})).unwrap_err().to_string(); diff --git a/docs/mcp.md b/docs/mcp.md index 6fc3dd98bf..76f8648ac3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3832,7 +3832,8 @@ without its formatting, taking the style at the caret. `document.exportDxf` (also `document.export` / `export` with format `dxf`) writes a CAD drawing, ASCII DXF R12 to 2018 (`version`: `R12`, `R13`, `R14`, `2000`, `2004`, `2007`, `2010`, `2013`, `2018`; default `2018`). Each layer becomes a DXF layer (hidden layers switched off, locked ones locked, non-printing ones not plotted; template layers left out), -straight paths become polylines, curved ones cubic splines through every anchor, fills their outlines followed by +straight paths become polylines (an open path of one straight segment becomes a line, except for a stroke weight +before 2000), curved ones cubic splines through every anchor, fills their outlines followed by solid hatches (laser and cutter software reads the outlines and skips hatches; R12 has no hatches: the outlines alone), strokes lines with their lineweight and a linetype per dash pattern, and placed images image entities linked to PNG or JPEG files (`rasterFormat`) written next to the drawing (`linked` in the result). Coordinates are y up diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 416501d553..9eb17cc09e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1041,7 +1041,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs. - EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. - EPS and PostScript `.ai` import undoes the TIFF predictor (`/Predictor 2`) of `FlateDecode` and `LZWDecode` data at 1, 2, 4 and 16 bits a component as well as 8: each component adds the same component of the pixel to its left within its bit width, 16-bit components add as big-endian numbers, and each row starts again (ISO 32000-1 §7.4.4.4; `crates/eps/src/import/tests_images.rs`, `crates/engine/src/cmd/fileio/tests_epsimport.rs`). At those depths the image drew the stored differences instead of its samples. - - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); 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`); CNC preset and VCarve QA remain pending. - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.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. - Placed documents' bitmaps on screen: when a bitmap made in the background is ready, the desktop app renders the canvas again only where the objects that show its file are, when the frame shown was rendered on the UI thread and that part is at most half the canvas. Bitmaps drawn or made since a frame began are kept for that frame past the 512 MiB budget, up to 2 GiB, so a view showing more than the budget does not make them again in every frame. A bitmap that would take them past 2 GiB is not kept, and its file draws exactly at that zoom. A bitmap that can't be made (the file can't be read and there is no preview) shows the light placeholder and is not asked for again. Each ready bitmap used to render the whole canvas again, and bitmaps past the budget and bitmaps that could not be made were asked for again in every frame (`crates/render/src/tests/placed_document.rs`). From b493e601a91fbec9b893691c48fa4470f4bbdfc5 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 30 Oct 2026 23:40:58 -0700 Subject: [PATCH 28/98] Docs: leave the DXF row of file-format-parity.md to #4796 The ELLIPSE note stays in docs/gaps.md; the open #4796 rewrites the same file-format-parity.md row. --- docs/file-format-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ffab9f5859..dc23c77b7e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -98,7 +98,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | 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) | | 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 `<title>`, 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; closed four-cubic ellipses, and circles scaled out of proportion, export as ELLIPSE in R13–2018; 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). CNC preset and VCarve QA remain open | +| 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). CNC preset and VCarve QA remain open | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | | PSD | open, place, export | yes (merged image, every mode and depth) | yes (layered, max editability) | placing as layers (Photoshop Import Options) missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | From 958566468abb925cd45bfd6a9fcdf6346a9e07b1 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 31 Oct 2026 00:29:28 -0700 Subject: [PATCH 29/98] DXF export: dashed polylines continue their linetype around vertices A polyline with a linetype now sets group 70 bit 128, so the dash pattern runs on through its vertices instead of restarting at each corner. This applies to LWPOLYLINE and the R12 POLYLINE. --- crates/cad/src/scene.rs | 3 ++- crates/cad/src/tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index a292d5a7f5..34e69d6edb 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -872,7 +872,8 @@ impl<'a> Scene<'a> { let pts: Vec<Point> = pts.iter().map(|p| self.pt(*p)).collect(); let layer = self.layer.clone(); let h = self.handle(); - let flags = i64::from(closed); + // Bit 128: a linetype runs on through the vertices instead of restarting at each one. + let flags = i64::from(closed) | if style.linetype.is_some() { 128 } else { 0 }; if self.o.version.lwpolyline() { self.e.entity("LWPOLYLINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbPolyline"); self.e.int(90, pts.len() as i64); diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index aff8489c44..f02047492a 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -696,6 +696,30 @@ fn dashes_that_start_part_way_into_their_pattern_are_outlined() { assert_eq!(get(&entities(&editable)[0], 6), Some("DASHED1")); } +#[test] +fn dashed_polylines_continue_their_pattern_around_corners() { + let rect = |dash: Option<Dash>| { + doc_with(|d| { + let mut n = path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0); + if let Some(st) = n.appearance.stroke_mut() { + st.dash = dash.clone(); + } + vec![n] + }) + }; + let flags = |out: &DxfOutput| entities(out).first().and_then(|e| get(e, 70).map(str::to_string)); + // Group 70 bit 128 makes the linetype run on through the vertices instead of restarting + // at each one; a solid polyline keeps only its closed bit. + let dashed = rect(Some(Dash { pattern: vec![6.0, 3.0], offset: 0.0, align_corners: false })); + for o in [opts(&dashed), DxfOptions { alter_paths: true, ..opts(&dashed) }, DxfOptions { preserve: Preserve::Editability, ..opts(&dashed) }] { + assert_eq!(flags(&export(&dashed, &o).unwrap()).as_deref(), Some("129")); + } + let r12 = export(&dashed, &DxfOptions { version: DxfVersion::R12, ..opts(&dashed) }).unwrap(); + assert_eq!((kinds(&r12).first().map(String::as_str), flags(&r12).as_deref()), (Some("POLYLINE"), Some("129"))); + let solid = rect(None); + assert_eq!(flags(&export(&solid, &opts(&solid)).unwrap()).as_deref(), Some("1")); +} + fn text_doc(text: &str, size: f64) -> Document { doc_with(|d| { let style = CharStyle { size, fill: Paint::solid(Color::rgb(0.0, 0.5, 0.0)), ..CharStyle::default() }; From 2a5e2e8588e3f3bc497c621c83b291659a1ba00d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 30 Oct 2026 22:40:49 -0700 Subject: [PATCH 30/98] Blob Brush: the tip preview draws 0% Roundness as thin as the brush The Tool Options tip preview floored the short axis at 1% of the long one, while the brush draws 0% Roundness at 0.1%. The preview now uses the same 0.1% minimum, with a test that compares it with the tip the brush draws. --- crates/ui-egui/src/dialogs/freehand.rs | 8 +++++++- crates/ui-egui/src/dialogs/freehand/tests.rs | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/freehand.rs b/crates/ui-egui/src/dialogs/freehand.rs index 85d8630567..bd6068638c 100644 --- a/crates/ui-egui/src/dialogs/freehand.rs +++ b/crates/ui-egui/src/dialogs/freehand.rs @@ -276,7 +276,7 @@ fn blob_tip_preview(ui: &mut egui::Ui, d: &mut Dialog) { let u = egui::vec2(a.cos() as f32, -a.sin() as f32); let v = egui::vec2(-u.y, u.x); let long = 18.0; - let short = long * (d.f64("roundness", 100.0).clamp(0.0, 100.0) as f32 / 100.0).max(0.01); + let short = long * blob_tip_short_ratio(d.f64("roundness", 100.0)); let dab = |center: egui::Pos2, color: egui::Color32| { let points = (0..32) .map(|i| { @@ -295,6 +295,12 @@ fn blob_tip_preview(ui: &mut egui::Ui, d: &mut Dialog) { } } +/// The preview tip's short axis as a fraction of its long axis: Roundness in percent, with 0 +/// percent drawn as 0.1 percent, the minimum the brush draws with. +fn blob_tip_short_ratio(roundness: f64) -> f32 { + (roundness.clamp(0.0, 100.0) as f32 / 100.0).max(0.001) +} + /// OK: set the tool's options. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> { let values: Map<String, Value> = KEYS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect(); diff --git a/crates/ui-egui/src/dialogs/freehand/tests.rs b/crates/ui-egui/src/dialogs/freehand/tests.rs index 0136d25c51..81aeae9b5a 100644 --- a/crates/ui-egui/src/dialogs/freehand/tests.rs +++ b/crates/ui-egui/src/dialogs/freehand/tests.rs @@ -28,6 +28,14 @@ fn blob_tip_fields_confirm_and_cancel() { assert_eq!(app.session.tool_options_of("blobBrush")["roundness"], json!(0.0)); } +#[test] +fn the_tip_preview_is_as_flat_as_the_tip_the_brush_draws() { + for roundness in [-5.0, 0.0, 0.05, 0.1, 1.0, 50.0, 100.0, 120.0] { + let drawn = vectorcraft_tools::draw2::blob_tip(0.0, roundness).as_coeffs()[3] as f32; + assert!((blob_tip_short_ratio(roundness) - drawn).abs() < 1e-6, "roundness {roundness}: drawn {drawn}"); + } +} + /// The labels the open dialog paints. fn painted(app: &mut VectorcraftApp) -> String { crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) From b1ed0a512b207794dc8a42cea0448bd21a0c6c1b Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 30 Oct 2026 22:44:50 -0700 Subject: [PATCH 31/98] Brushes: opacity masks on art brush pieces bend with the art An art, scatter or pattern brush piece whose art carries a linked opacity mask was bent along the path while the mask's art kept its place in the brush's own coordinates, so the mask no longer covered the piece. The mask's art now bends and has its strokes scaled with the object it masks, on bent runs, on straight runs and where the brush narrows along the path. A mask whose art cannot bend becomes an empty group. The anchor budget for a brushed stroke counts the mask's art as well. --- crates/brush/src/warp.rs | 71 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index 99f7e63048..d66d5679fb 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -41,7 +41,8 @@ const MASK_STEPS: usize = 32; /// space, so the art keeps its curves (#3926). Where that cannot be done in no more anchors than /// chords would take, the path is flattened with `flat_tol` (art space) and subdivided so no chord /// is longer than `max_du` along x, as it always was. Stroke widths scale by `width_scale`. -/// Text, images and symbol instances can't bend and are dropped. +/// Text, images and symbol instances can't bend and are dropped. The art of a linked opacity mask +/// bends with the object it masks. pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, doc_tol: f64, width_scale: f64) -> Option<Node> { let mut out = n.clone(); out.appearance.scale_strokes(width_scale); @@ -63,9 +64,21 @@ pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max } _ => return None, } + map_mask(&mut out, |art| warp_node(art, f, flat_tol, max_du, doc_tol, width_scale)); Some(out) } +/// Replace the art of `n`'s linked opacity mask, if it has one, with what `map` makes of it. Where +/// `map` draws nothing, the mask's art becomes an empty group. +fn map_mask(n: &mut Node, map: impl FnOnce(&Node) -> Option<Node>) { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) + && m.linked + { + let id = m.art.id; + m.art = Arc::new(map(&m.art).unwrap_or_else(|| Node::group(id, vec![]))); + } +} + /// Each subpath of `p` through `f`: fitted cubics when they take no more anchors than the chords /// would, else the chords. Points `f` cannot place (not finite) are left out of the chords. fn warp_path(p: &PathData, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, fit_tol: f64) -> PathData { @@ -338,7 +351,8 @@ fn affine_fit(f: &dyn Fn(Point) -> Point, b: Rect, tol: f64) -> Option<Affine> { /// Everything else matches [`warp_node`] deliberately, so curves are the only difference between /// the two routes: the same node kinds are kept and the rest dropped, `live` is cleared on every /// path, an empty path or a group left with no children is dropped, and strokes are scaled by -/// `width_scale` at every level rather than by the transform's own factor. +/// `width_scale` at every level rather than by the transform's own factor, the art of linked +/// opacity masks included. fn affine_node(n: &Node, a: Affine, width_scale: f64) -> Option<Node> { let mut out = prepared(n, width_scale)?; out.transform(a, false); @@ -365,6 +379,7 @@ fn prepared(n: &Node, width_scale: f64) -> Option<Node> { } _ => return None, } + map_mask(&mut out, |art| prepared(art, width_scale)); Some(out) } @@ -416,6 +431,7 @@ fn widen_strokes(n: &Node, factor: &dyn Fn(&Node) -> f64) -> Node { if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } = &mut out.kind { *children = children.iter().map(|c| Arc::new(widen_strokes(c, factor))).collect(); } + map_mask(&mut out, |art| Some(widen_strokes(art, factor))); out } @@ -590,6 +606,9 @@ fn anchors_of(n: &Node) -> usize { if let Some(c) = n.children() { stack.extend(c.iter().map(Arc::as_ref)); } + if let Some(m) = &n.extras().mask { + stack.push(&m.art); + } } total } @@ -1133,4 +1152,52 @@ mod tests { } } } + + /// A linked opacity mask on brush art bends and widens with the art, on a bent run and on a + /// straight one, so a mask drawn over the art still covers it. An unlinked mask stays put. + #[test] + fn a_linked_opacity_mask_bends_with_the_art() { + let b = Rect::new(0.0, -20.0, 40.0, 20.0); + let stroked = |id: u64| { + Node::path( + NodeId(id), + vectorcraft_geom::shapes::ellipse(b), + Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + 2.0, + ), + ) + }; + let masked = |linked: bool| { + let mut n = stroked(0); + let mut mask = vectorcraft_doc::OpacityMask::new(stroked(1), true); + mask.linked = linked; + n.extras_mut().mask = Some(Box::new(mask)); + n + }; + let round = vectorcraft_geom::shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath(); + let bent_track = tracks(&round, 0.05).remove(0); + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((320.0, 0.0)); + let straight_track = tracks(&line, 0.01).remove(0); + let along = AlongMap::linear(b.width(), 100.0, 140.0); + for (name, t) in [("bent", &bent_track), ("straight", &straight_track)] { + let out = bend(t, &masked(true), b, along, 2.0).expect("the art is drawn"); + let mask = out.extras().mask.as_ref().expect("the mask is kept"); + assert_eq!(mask.art.kind, out.kind, "{name} run: the mask's art bends with the art it was drawn over"); + assert_eq!(mask.art.appearance, out.appearance, "{name} run: the mask's strokes widen with the art's"); + + let out = bend(t, &masked(false), b, along, 2.0).expect("the art is drawn"); + let mask = out.extras().mask.as_ref().expect("the mask is kept"); + assert_eq!(*mask.art, stroked(1), "{name} run: an unlinked mask stays where it was"); + + let out = bend_widened(t, &masked(true), b, along, 2.0, &|_| 0.5).expect("the art is drawn"); + let mask = out.extras().mask.as_ref().expect("the mask is kept"); + assert_eq!(mask.art.kind, out.kind, "{name} run, widened: the mask's art bends with the art"); + assert_eq!(mask.art.appearance, out.appearance, "{name} run, widened: the mask's strokes narrow with the art's"); + } + assert_eq!(anchors_of(&masked(true)), 8, "the anchor budget counts the mask's art"); + } } From 8aa8bb4d9d8a63ffbcd6e196d118b6e84a89f8f9 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 30 Oct 2026 18:53:25 -0700 Subject: [PATCH 32/98] Render: a mask art root, symbol art root or thumbnail keeps its own opacity mask The renderer drew the root node of an opacity mask's art, of placed symbol art, of an art brush piece, of an Image Trace source image and of a node thumbnail without its own opacity mask. Make Opacity Mask uses the top object as the mask art, so a mask made from an object that already had an opacity mask ignored that inner mask. These roots now draw through the same masked group as any other node, still drawn whatever their visibility and bounds, and an incomplete inner mask still leaves out the outer object. --- crates/render/src/brush_fx.rs | 32 +++++++- crates/render/src/renderer.rs | 2 +- crates/render/src/renderer/nodes.rs | 46 +++++++---- crates/render/src/tests.rs | 121 ++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 17 deletions(-) diff --git a/crates/render/src/brush_fx.rs b/crates/render/src/brush_fx.rs index cb370520fa..c19bfb6e6b 100644 --- a/crates/render/src/brush_fx.rs +++ b/crates/render/src/brush_fx.rs @@ -212,7 +212,7 @@ impl Renderer { self.draw_kept_node(ctx, f, p); self.fx_owner = owner; } else { - self.draw_node(ctx, f, p, true); + self.draw_forced_arc(ctx, f, p); } } (self.made_again, self.cur) = outer; @@ -260,6 +260,36 @@ mod tests { assert_eq!(again.pixels, img.pixels); } + #[test] + fn art_brush_piece_applies_its_root_mask() { + use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind, Colorization}; + use vectorcraft_doc::OpacityMask; + + let mut d = doc_with_line(Some("Masked art"), 1.0); + let mut art = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(0.0, -5.0, 10.0, 5.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + let black = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + art.extras_mut().mask = Some(Box::new(OpacityMask::new(black, true))); + let brush = Brush { + name: "Masked art".into(), + kind: BrushKind::Art(ArtBrush { art, scale: ArtScale::Stretch, colorization: Colorization::None, ..Default::default() }), + }; + vectorcraft_brush::store(&mut d, &[brush]); + + let mut renderer = Renderer::new(); + for _ in 0..2 { + let img = render(&mut renderer, &d); + assert_eq!(img.pixel(100, 50), [255, 255, 255, 255]); + } + } + /// Object opacity fades a brushed stroke's art as a whole, with or without a dash and a fill: /// the same as the object at full opacity inside a group at that opacity. #[test] diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 26702d57b0..3593bf0413 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -361,7 +361,7 @@ impl Renderer { self.begin_frame(); (self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None); self.clip_paths.clear(); - self.draw_node(&mut ctx, &frame, n, true); + self.draw_forced_arc(&mut ctx, &frame, &Arc::new(n.clone())); let pm = self.finish(&mut ctx, vello_cpu::RasterizerSettings::default()); self.end_frame(); Some(Rendered { width: size, height: size, pixels: pm.data_as_u8_slice().to_vec() }) diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index edf3409ac2..6586be0b3b 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -157,23 +157,39 @@ impl Renderer { self.stats.drawn += 1; return; } - if let Some(m) = a.extras().mask.as_deref() - && !m.disabled - && !f.opts.outline - && !self.is_shape(a) - { - // A masked object is a non-isolated group of its own, drawn over its own pixels: the - // mask is rendered over those only. - let bounds = self.bounds_of(f.symbols, a); - let Some((r, sub)) = group::area(ctx, f, bounds) else { return }; - let Some(mask) = self.mask_values(&sub, m, r.w, r.h) else { return }; - let blends = a.blend != vectorcraft_color::BlendMode::Normal || (!a.isolate && self.blends_through(a)); - self.group(ctx, f, Composite { mask: Some(mask), blends, bounds, ..Default::default() }, &mut |r, c, fr| r.draw_arc_body(c, fr, a)); + if self.draw_masked_arc(ctx, f, a, false) { return; } self.draw_arc_body(ctx, f, a); } + /// Draw `a` through its opacity mask when it has one. Forced roots draw regardless of their + /// visibility and bounds. + fn draw_masked_arc(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>, force: bool) -> bool { + let Some(m) = a.extras().mask.as_deref().filter(|m| !m.disabled && !f.opts.outline && !self.is_shape(a)) else { return false }; + // A masked object is a non-isolated group of its own, drawn over its own pixels: the + // mask is rendered over those only. + let bounds = self.bounds_of(f.symbols, a); + let Some((r, sub)) = group::area(ctx, f, bounds) else { return true }; + let Some(mask) = self.mask_values(&sub, m, r.w, r.h) else { return true }; + let blends = a.blend != vectorcraft_color::BlendMode::Normal || (!a.isolate && self.blends_through(a)); + self.group(ctx, f, Composite { mask: Some(mask), blends, bounds, ..Default::default() }, &mut |r, c, fr| { + if force { + r.draw_node(c, fr, a, true); + } else { + r.draw_arc_body(c, fr, a); + } + }); + true + } + + /// Draw an `Arc` regardless of its visibility and bounds, applying its opacity mask. + pub(crate) fn draw_forced_arc(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>) { + if !self.draw_masked_arc(ctx, f, a, true) { + self.draw_node(ctx, f, a, true); + } + } + /// The coverage of opacity mask `m` per output pixel (row-major): its art rendered offscreen, /// as luminance ([`mask_value`]). pub(super) fn mask_values(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> Option<Vec<u8>> { @@ -489,7 +505,7 @@ impl Renderer { if kept { self.draw_kept_node(ctx, f, art); } else { - self.draw_node(ctx, f, art, true); + self.draw_forced_arc(ctx, f, art); } self.made_again = outer; } @@ -500,7 +516,7 @@ impl Renderer { pub(crate) fn draw_kept_node(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>) { let live = vectorcraft_doc::live::is_live(a).then(|| a.clone()); let outer = std::mem::replace(&mut self.live_root, live); - self.draw_node(ctx, f, a, true); + self.draw_forced_arc(ctx, f, a); self.live_root = outer; } @@ -518,7 +534,7 @@ impl Renderer { fn draw_trace_view(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc<Node>], view: TraceView, knockout: bool) { let Some((source, shapes)) = children.split_first() else { return }; if view.shows_source() && matches!(source.kind, NodeKind::Image(_)) { - self.draw_node(ctx, f, source, true); + self.draw_forced_arc(ctx, f, source); } if view.shows_result() { self.draw_children(ctx, f, shapes, knockout); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 6b6ec3e794..6f15ae3135 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -662,6 +662,39 @@ fn trace_source_draws_the_source_image_of_the_listed_objects() { } } +#[test] +fn trace_source_applies_the_hidden_image_mask() { + use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind, OpacityMask}; + + let mut png = Vec::new(); + image::RgbaImage::from_pixel(1, 1, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut d = Document::new(100.0, 100.0); + d.images.insert("trace-source".into(), ImageBlob::new("image/png", png)); + let image = ImageObject { + key: "trace-source".into(), + width: 1, + height: 1, + xf: Affine::translate((10.0, 10.0)) * Affine::scale(80.0), + link: None, + placement: Default::default(), + pixels: None, + }; + let mut source = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); + source.visible = false; + let mask = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), Paint::solid(Color::WHITE), Paint::None, 0.0); + source.extras_mut().mask = Some(Box::new(OpacityMask::new(mask, true))); + let result = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0); + let mut trace = Node::group(d.alloc_id(), vec![Arc::new(source), Arc::new(result)]); + trace.extras_mut().trace = Some(Box::new(serde_json::json!({ "view": "tracingResult" }))); + let trace_id = trace.id; + d.insert(Some(d.layers[0].id), 0, trace).unwrap(); + + let opts = RenderOptions { trace_views: true, trace_source: Some(Arc::new(vec![trace_id])), background: Some([255; 4]), ..Default::default() }; + let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!(img.pixel(30, 50), [255, 0, 0, 255]); + assert_eq!(img.pixel(70, 50), [255, 255, 255, 255]); +} + #[test] fn artboards_white_on_pasteboard() { let d = Document::new(50.0, 50.0); @@ -747,6 +780,94 @@ fn opacity_mask_without_clip_and_inverted() { assert_eq!(img.pixel(80, 50), [255, 0, 0, 255], "inverted clip background → visible"); } +#[test] +fn opacity_mask_art_root_applies_its_own_mask() { + use vectorcraft_doc::OpacityMask; + + let mut d = Document::new(100.0, 100.0); + let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let mut art = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::WHITE), Paint::None, 0.0); + let inner = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), Paint::solid(Color::WHITE), Paint::None, 0.0); + art.extras_mut().mask = Some(Box::new(OpacityMask::new(inner, true))); + art.visible = false; + object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, true))); + let object_id = object.id; + d.insert(Some(d.layers[0].id), 0, object).unwrap(); + + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut renderer = Renderer::new(); + let img = renderer.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!(img.pixel(30, 50), [255, 0, 0, 255]); + assert_eq!(img.pixel(70, 50), [255, 255, 255, 255]); + let view = renderer.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { mask_view: Some(object_id), ..opts }); + assert_eq!(view.pixel(30, 50), [255, 255, 255, 255]); + assert_eq!(view.pixel(70, 50), [0, 0, 0, 255]); +} + +#[test] +fn undecodable_image_in_mask_art_root_mask_leaves_out_outer_object() { + use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind, OpacityMask}; + + let mut d = Document::new(100.0, 100.0); + d.images.insert("broken-root-mask-image".into(), ImageBlob::new("image/png", b"not a PNG".to_vec())); + let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let mut art = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::WHITE), Paint::None, 0.0); + let image = ImageObject { + key: "broken-root-mask-image".into(), + width: 20, + height: 20, + xf: Affine::translate((30.0, 30.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + art.extras_mut().mask = Some(Box::new(OpacityMask::new(Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))), false))); + object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, true))); + let object_id = object.id; + d.insert(Some(d.layers[0].id), 0, object).unwrap(); + + let opts = RenderOptions { background: Some([255; 4]), ..Default::default() }; + let mut renderer = Renderer::new(); + let img = renderer.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!(img.pixel(70, 50), [255, 255, 255, 255]); + let view = renderer.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { mask_view: Some(object_id), ..opts }); + assert_eq!(view.pixel(70, 50), [0, 0, 0, 255]); +} + +#[test] +fn placed_symbol_art_root_applies_its_own_mask() { + use vectorcraft_doc::{NodeKind, OpacityMask}; + + for grouped in [true, false] { + let mut d = Document::new(100.0, 100.0); + let red = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let mut art = if grouped { Node::group(d.alloc_id(), vec![Arc::new(red)]) } else { red }; + let inner = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), Paint::solid(Color::WHITE), Paint::None, 0.0); + art.extras_mut().mask = Some(Box::new(OpacityMask::new(inner, true))); + d.symbols.push(vectorcraft_doc::Symbol { name: "Masked".into(), art: Arc::new(art) }); + let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Masked".into(), xf: Affine::IDENTITY }); + d.insert(Some(d.layers[0].id), 0, instance).unwrap(); + + let img = render(&d); + assert_eq!(img.pixel(30, 50), [255, 0, 0, 255], "grouped {grouped}"); + assert_eq!(img.pixel(70, 50), [255, 255, 255, 255], "grouped {grouped}"); + } +} + +#[test] +fn node_thumbnail_applies_the_root_mask() { + use vectorcraft_doc::OpacityMask; + + let mut d = Document::new(100.0, 100.0); + let mut node = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let mask = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), Paint::solid(Color::WHITE), Paint::None, 0.0); + node.extras_mut().mask = Some(Box::new(OpacityMask::new(mask, true))); + + let img = Renderer::new().render_node_thumbnail(&d, &node, 100, Some([255; 4])).unwrap(); + assert_eq!(img.pixel(25, 50), [255, 0, 0, 255]); + assert_eq!(img.pixel(75, 50), [255, 255, 255, 255]); +} + #[test] fn opacity_mask_with_undecodable_image_leaves_out_object() { use vectorcraft_doc::{Effect, ImageBlob, ImageObject, NodeKind, OpacityMask}; From dd83e063a4811149794ce8192267a347bad88f11 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 30 Oct 2026 18:40:38 -0700 Subject: [PATCH 33/98] Render: pattern tiles draw undecoded-image marks when the view does Pattern tiles were drawn with the default render options, so an image whose pixels cannot be decoded drew nothing inside a pattern fill even when the render marks undecoded images, as the canvas does. Tiles now follow the caller's mark_undecoded_images setting, and the tile cache keys on it. An opacity mask with such a pattern now treats the mark as part of the mask, as it does for an undecodable image outside a pattern. --- crates/render/src/pattern.rs | 30 ++++++++----- crates/render/src/renderer/nodes.rs | 2 - crates/render/src/tests.rs | 68 +++++++++++++++++++++++++++-- 3 files changed, 85 insertions(+), 15 deletions(-) diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index ab1270317e..f23786b49e 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -4,7 +4,7 @@ //! ([`PatternDef::period`]) is rasterized at the device scale it is painted at, rounded up to a //! power of two and capped ([`raster_scale`]), and used as an image paint with `Extend::Repeat`, //! with the paint transform mapping pixels → pattern space → document. Tiles are cached per -//! pattern name and raster scale (and ink plane, see [`crate::ink`]) within +//! pattern name, raster scale, undecoded-image mark setting and ink plane (see [`crate::ink`]) within //! [`lru::PATTERN_BUDGET`]. A tile is used while the pattern's definition is the one it was //! rasterized from (art `Arc` identity, tile, tile type, overlap): the pattern's "revision", and, //! when its art shows more than itself (a symbol, another pattern, an image, type, a brush), while @@ -33,9 +33,9 @@ use crate::{Frame, RenderOptions, Rendered, Renderer}; /// Largest super-tile raster side (pixels). const MAX_TILE_PX: f64 = 2048.0; -/// What a tile is kept under: the pattern's name and the bits of its raster scale -/// ([`raster_scale`]). -type TileKey = (String, u64, Ink); +/// What a tile is kept under: the pattern's name, the bits of its raster scale +/// ([`raster_scale`]), ink plane and undecoded-image mark setting. +type TileKey = (String, u64, Ink, bool); /// What a tile was drawn from besides its pattern's own art, for art that shows more than itself: /// the art of every pattern and symbol and the pixels of every image in the document, the @@ -169,12 +169,20 @@ pub(crate) fn raster_scale(s: f64, period: (f64, f64)) -> f64 { /// Rasterize `region` (pattern space) of the infinite tiling into a `w`×`h` pixmap, colours as /// `ink` paints them. -fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w: u16, h: u16, ink: Ink) -> (Pixmap, bool) { - let (w, h) = (w.max(1), h.max(1)); +fn rasterize( + r: &mut Renderer, + doc: &Document, + def: &PatternDef, + region: Rect, + size: (u16, u16), + ink: Ink, + mark_undecoded_images: bool, +) -> (Pixmap, bool) { + let (w, h) = (size.0.max(1), size.1.max(1)); let mut ctx = crate::single_threaded_context(w, h); let view = Affine::scale_non_uniform(w as f64 / region.width().max(1e-9), h as f64 / region.height().max(1e-9)) * Affine::translate(-region.origin().to_vec2()); - let opts = RenderOptions::default(); + let opts = RenderOptions { mark_undecoded_images, ..Default::default() }; r.image_left_out = Some(false); let pattern_images_left_out = images_left_out(); r.check_plugins(); @@ -254,7 +262,7 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, let (pw, ph) = def.period(); let scale = raster_scale(s, (pw, ph)); let params = (def.tile, def.tile_type, def.overlap); - let key = (name.to_string(), scale.to_bits(), ink); + let key = (name.to_string(), scale.to_bits(), ink, f.opts.mark_undecoded_images); let cms = vectorcraft_color::cms::active(); let hit = CACHE.with(|c| { let mut c = c.borrow_mut(); @@ -283,7 +291,9 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, let w = (pw * scale).ceil().clamp(1.0, 4096.0) as u16; let h = (ph * scale).ceil().clamp(1.0, 4096.0) as u16; let before = crate::paint::decode_failures(); - let Some((pm, image_left_out)) = with_tile_renderer(|r| rasterize(r, doc, def, Rect::new(0.0, 0.0, pw, ph), w, h, ink)) else { + let Some((pm, image_left_out)) = + with_tile_renderer(|r| rasterize(r, doc, def, Rect::new(0.0, 0.0, pw, ph), (w, h), ink, f.opts.mark_undecoded_images)) + else { ctx.set_paint(missing()); return true; }; @@ -322,7 +332,7 @@ pub fn render_pattern_swatch(doc: &Document, name: &str, size: u32) -> Option<Re let side = w.max(h); let region = Rect::from_center_size((w / 2.0, h / 2.0), (side, side)); let px = size.clamp(1, 1024) as u16; - let (pm, _) = with_tile_renderer(|r| rasterize(r, doc, def, region, px, px, Ink::Display))?; + let (pm, _) = with_tile_renderer(|r| rasterize(r, doc, def, region, (px, px), Ink::Display, false))?; Some(Rendered { width: px as u32, height: px as u32, pixels: pm.data_as_u8_slice().to_vec() }) } diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index 6586be0b3b..97cd1e64eb 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -207,8 +207,6 @@ impl Renderer { let failures = crate::paint::decode_failures(); let pattern_images_left_out = crate::pattern::images_left_out(); self.draw_kept_node(&mut mctx, &Frame { mt: false, ink: Ink::Display, ..*f }, &m.art); - // Pattern tiles are drawn without undecoded-image marks, so an image a tile left out counts - // with the marks on too. let incomplete = self.image_left_out.unwrap_or(false) || crate::pattern::images_left_out() > pattern_images_left_out || (!f.opts.mark_undecoded_images && crate::paint::decode_failures() > failures); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 6f15ae3135..767f1eda8a 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -944,9 +944,33 @@ fn opacity_mask_with_missing_image_in_pattern_leaves_out_object() { let img = r.render(&d, 100, 100, Affine::IDENTITY, &opts); assert_eq!(img.pixel(70, 50), [255, 255, 255, 255], "frame {frame}: incomplete pattern mask leaves out the object"); } - // Pattern tiles draw no undecoded-image mark, so the object stays out with the marks on too. - let marked = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { mark_undecoded_images: true, ..opts.clone() }); - assert_eq!(marked.pixel(70, 50), [255, 255, 255, 255], "a tile without the mark leaves out the object"); +} + +#[test] +fn opacity_mask_with_marked_image_in_pattern_draws_object() { + use vectorcraft_doc::pattern::{PatternDef, pattern_paint}; + use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind, OpacityMask}; + + let mut d = Document::new(100.0, 100.0); + d.images.insert("cut-mask".into(), ImageBlob::new("image/png", b"not a PNG: pattern mask".to_vec())); + let image = + ImageObject { key: "cut-mask".into(), width: 20, height: 20, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); + let mut pattern = PatternDef::new("Cut mask", vec![Arc::new(image)]); + pattern.tile = Rect::new(0.0, 0.0, 20.0, 20.0); + d.patterns.push(pattern); + let art = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), pattern_paint("Cut mask"), Paint::None, 0.0); + let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + d.insert(Some(d.layers[0].id), 0, object).unwrap(); + + let mut r = Renderer::new(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let off = r.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!(off.pixel(70, 50), [255, 255, 255, 255], "an unmarked missing image leaves out the object"); + let on = r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { mark_undecoded_images: true, ..opts }); + assert_eq!(on.pixel(70, 50), [255, 0, 0, 255], "the marked image keeps the mask complete"); + assert!(on.pixel(15, 15)[1] < 255, "the marked pattern contributes mask coverage"); } #[test] @@ -1488,6 +1512,44 @@ fn an_undecodable_image_is_tried_once_and_marked_on_screen() { assert_eq!((attempts() - tried, decode_failures() - failed), (1, 3), "and counts it without decoding it again"); } +#[test] +fn pattern_tile_follows_undecoded_image_mark_in_both_cache_orders() { + use vectorcraft_doc::pattern::{PatternDef, pattern_paint}; + use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind}; + + let mut d = Document::new(80.0, 80.0); + d.images.insert("cut-pattern".into(), ImageBlob::new("image/png", b"not a PNG: pattern fill".to_vec())); + let image = ImageObject { + key: "cut-pattern".into(), + width: 20, + height: 20, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + }; + let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); + let mut pattern = PatternDef::new("Cut pattern", vec![Arc::new(image)]); + pattern.tile = Rect::new(0.0, 0.0, 20.0, 20.0); + d.patterns.push(pattern); + let shape = rect_node(&mut d, Rect::new(10.0, 10.0, 70.0, 70.0), pattern_paint("Cut pattern"), Paint::None, 0.0); + d.insert(Some(d.layers[0].id), 0, shape).unwrap(); + + let mut r = Renderer::new(); + for order in [[false, true, false], [true, false, true]] { + for mark in order { + let opts = RenderOptions { mark_undecoded_images: mark, ..Default::default() }; + let pixel = r.render(&d, 80, 80, Affine::IDENTITY, &opts).pixel(15, 15); + assert_eq!(pixel[3] > 0, mark, "mark_undecoded_images {mark}: {pixel:?}"); + if mark { + assert_eq!(pixel[0], pixel[1], "the pattern draws a gray mark"); + assert_eq!(pixel[1], pixel[2], "the pattern draws a gray mark"); + assert!((120..=140).contains(&pixel[0]), "the pattern draws a gray mark: {pixel:?}"); + } + } + } +} + /// The rasters a renderer keeps of an object's raster effects are made again when /// `mark_undecoded_images` changes: a blurred image whose pixels can't be decoded shows its blurred /// mark only while the option is on. From 875d3301f55d48e887d0274f1494e2a304ac0a6b Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 31 Oct 2026 00:42:46 -0700 Subject: [PATCH 34/98] Transparency: Make Opacity Mask over several layers masks their art transparency.makeOpacityMask with two or more layers below the mask object (or a layer and objects of other layers) wrapped the layers themselves in a new top-level group, so a layer ended up inside a group. A layer among several masked items now stands for its art: the masked group takes the layer's visible, unlocked objects, a hidden, locked or template layer gives none, and the layers stay where they are. One layer under a mask object, or one layer alone, is masked as before. --- crates/engine/src/cmd/opacitymask.rs | 59 +++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index d5a7f600dd..8d65308600 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> { "Make Opacity Mask", ["Window", "Transparency"], None, - "{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask; one target (an object or a layer) is refused in isolation mode and in pattern editing mode → {id, editing}", + "{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several, a layer among several by its art). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask; one target (an object or a layer) is refused in isolation mode and in pattern editing mode → {id, editing}", can_make, make ), @@ -206,6 +206,24 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> { let invert = bool_or(p, "invert", s.menu.new_masks_inverted); // Several objects: the top one is the mask. One: an empty mask, drawn in mask editing. let (art, mask_id) = if below.is_empty() { (&ids[..], None) } else { (below, Some(top)) }; + // Several objects go into a new group, and a layer can't: a layer among them stands for its + // visible, unlocked art, which the group takes from it. A hidden, locked or template layer + // gives none. + let contents; + let art = if art.len() > 1 && art.iter().any(|id| d.node(*id).is_some_and(Node::is_layer)) { + let objects = art.iter().flat_map(|id| match d.node(*id) { + Some(n) if n.is_layer() && (!n.visible || n.locked || n.is_template()) => vec![], + Some(n) if n.is_layer() => n.layer_art(true), + _ => vec![*id], + }); + contents = super::edit::outermost(d, d.paint_order(objects)); + if contents.is_empty() { + return Err(bad(C, "the layers hold no art to mask")); + } + &contents[..] + } else { + art + }; let (id, layer) = s.edit("Make Opacity Mask", |d, sel| { let mask_art = match mask_id { Some(m) => (*d.remove(m)?).clone(), @@ -492,6 +510,45 @@ mod tests { assert!(!n.extras().mask.as_ref().unwrap().clip); } + #[test] + fn make_masks_the_art_of_layers_under_a_mask_object() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let rect = |s: &mut Session, x: u64| { + s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap() + }; + let l1 = s.doc().unwrap().doc.layers[0].id.0; + let (a, a2) = (rect(&mut s, 0), rect(&mut s, 30)); + let l2 = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap(); + let (b, b2) = (rect(&mut s, 60), rect(&mut s, 90)); + s.execute("layer.new", &json!({})).unwrap(); + let top = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 150, "height": 50})).unwrap()["id"].as_u64().unwrap(); + let layer_ids = |s: &Session| s.doc().unwrap().doc.layers.iter().map(|l| l.id.0).collect::<Vec<_>>(); + let before = s.doc().unwrap().doc.layers.clone(); + // Two layers, and a layer with an object of another layer: the masked group takes the + // layers' art, and the layers stay where they are. + for (ids, grouped, left) in [(vec![l1, l2, top], vec![a, a2, b, b2], vec![]), (vec![l1, b, top], vec![a, a2, b], vec![b2])] { + let g = s.execute("transparency.makeOpacityMask", &json!({ "ids": ids })).unwrap()["id"].as_u64().unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(layer_ids(&s), before.iter().map(|l| l.id.0).collect::<Vec<_>>(), "{ids:?}: no layer moved"); + let n = d.node(NodeId(g)).unwrap(); + assert!(!n.is_layer() && n.extras().mask.is_some(), "{ids:?}"); + assert!(d.parent_of(NodeId(g)).and_then(|p| d.node(p)).is_some_and(Node::is_layer), "{ids:?}: the group is in a layer"); + assert_eq!(n.children().unwrap().iter().map(|c| c.id.0).collect::<Vec<_>>(), grouped, "{ids:?}"); + for id in left { + assert_eq!(d.parent_of(NodeId(id)), Some(NodeId(l2)), "{ids:?}: {id} stays in its layer"); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers, before, "{ids:?}: undo"); + } + // A hidden layer gives none of its art. + s.execute("layer.setProps", &json!({"id": l2, "visible": false})).unwrap(); + let g = s.execute("transparency.makeOpacityMask", &json!({ "ids": [l1, l2, top] })).unwrap()["id"].as_u64().unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.node(NodeId(g)).unwrap().children().unwrap().iter().map(|c| c.id.0).collect::<Vec<_>>(), vec![a, a2]); + assert_eq!((d.parent_of(NodeId(b)), d.parent_of(NodeId(b2))), (Some(NodeId(l2)), Some(NodeId(l2)))); + } + #[test] fn release_restores_mask_art_and_undo_restores_mask() { let (mut s, a, b) = session_with_two(); From 1f31b6ed4a7b4c2ce409bdae59a5cac137e2c891 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 31 Oct 2026 18:46:05 +0700 Subject: [PATCH 35/98] Find Font: Change and Change All leave hidden and locked text alone Squashed from #4852 onto main. --- crates/engine/src/cmd/fonts.rs | 85 ++++++++++++++++++++++++++++- crates/ui-egui/src/find_font.rs | 16 ++++++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + 28 files changed, 124 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 6b134afb60..827875b206 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> { "Replace Font", [], None, - "{from: {family, style?}, to: {family (any of its names; stored as the font's own family name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style) → {runs}", + "{from: {family, style?}, to: {family (any of its names; stored as the font's own family name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); text that is hidden or locked, in a hidden or locked group or layer, or on a locked artboard keeps its font → {runs, skipped?: the text objects left in the font, warning?}", has_doc, replace ), @@ -414,10 +414,18 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> { Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => f.family.clone(), _ => return Err(bad(C, format!("font family `{tf}` is not available"))), }; - let ids = scope(s, bool_or(p, "selectionOnly", false))?; let matches = |st: &vectorcraft_doc::CharStyle| { st.font_family.eq_ignore_ascii_case(&ff) && fs.as_ref().is_none_or(|x| st.font_style.eq_ignore_ascii_case(x)) }; + // Only text the Selection tool can reach changes: not hidden or locked, in a hidden or locked + // group or layer, or on a locked artboard. The text left in the font is counted. + let (ids, skipped) = { + let d = &s.doc()?.doc; + let editable = d.editable(); + let (ids, other): (Vec<NodeId>, Vec<NodeId>) = scope(s, bool_or(p, "selectionOnly", false))?.into_iter().partition(|id| editable(*id)); + let skipped = other.into_iter().filter(|id| text(d, *id).is_some_and(|t| t.runs.iter().any(|r| matches(&r.style)))).count(); + (ids, skipped) + }; let n = s.edit("Replace Font", |d, _| { let mut n = 0; for id in &ids { @@ -446,9 +454,17 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> { } Ok(n) })?; - Ok(json!({ "runs": n })) + let mut out = json!({ "runs": n }); + if skipped > 0 { + out["skipped"] = json!(skipped); + out["warning"] = json!(REPLACE_SKIPPED); + } + Ok(out) } +/// What Replace Font says when it left text in the font that it can't reach. +const REPLACE_SKIPPED: &str = "some text is hidden or locked, so its font wasn't replaced"; + fn select_font(s: &mut Session, p: &Value) -> Result<Value> { let family = str_param(p, "family").ok_or_else(|| bad("select.font", "missing `family`"))?.to_string(); let style = str_param(p, "style").map(str::to_string); @@ -621,6 +637,69 @@ mod tests { assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 0); assert!(s.doc().unwrap().selection.objects.is_empty()); } + + /// Replace Font leaves hidden and locked text in its font, and text inside a hidden or locked + /// group or layer, and says so. Text on a visible, unlocked template layer is changed. + #[test] + fn replace_font_skips_hidden_and_locked_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let text = |s: &mut Session, y: i32| { + s.execute("text.create", &json!({"x": 10, "y": y, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap() + }; + let mut ids = vec![]; + for y in [20, 40, 60, 80, 100] { + ids.push(text(&mut s, y)); + } + for (id, group, cmd) in + [(ids[1], false, "object.hide"), (ids[2], false, "object.lock"), (ids[3], true, "object.hide"), (ids[4], true, "object.lock")] + { + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + if group { + s.execute("object.group", &json!({})).unwrap(); + } + s.execute(cmd, &json!({})).unwrap(); + } + let mut layer_text = |props: &[Value]| { + let layer = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap(); + let id = text(&mut s, 140); + for p in props { + let mut p = p.clone(); + p["id"] = json!(layer); + s.execute("layer.setProps", &p).unwrap(); + } + id + }; + ids.push(layer_text(&[json!({"visible": false})])); + ids.push(layer_text(&[json!({"locked": true})])); + // Template on locks the layer; unlocking it makes its text editable again. + let template = layer_text(&[json!({"template": true}), json!({"locked": false})]); + let family = |s: &Session, id: u64| text_family(s, id); + let r = s.execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Source Sans 3"}})).unwrap(); + assert_eq!(r["runs"], 2, "{r}"); + assert_eq!(r["skipped"], 6, "{r}"); + assert_eq!(r["warning"], REPLACE_SKIPPED, "{r}"); + assert_eq!(family(&s, ids[0]), "Source Sans 3"); + assert_eq!(family(&s, template), "Source Sans 3"); + for id in &ids[1..] { + assert_eq!(family(&s, *id), "Source Serif 4", "object {id}"); + } + // The font stays in the document's list while the skipped text uses it. + let fams: Vec<String> = + s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().map(|f| f["family"].as_str().unwrap().to_string()).collect(); + assert_eq!(fams, ["Source Sans 3", "Source Serif 4"]); + // Only skipped text left: nothing changes, and the warning says why. + let r = s.execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Source Sans 3"}})).unwrap(); + assert_eq!((r["runs"].clone(), r["skipped"].clone(), r["warning"].clone()), (json!(0), json!(6), json!(REPLACE_SKIPPED)), "{r}"); + // A plain replacement carries no warning. + let r = s.execute("text.replaceFont", &json!({"from": {"family": "Source Sans 3"}, "to": {"family": "Inter"}})).unwrap(); + assert_eq!(r, json!({"runs": 2})); + } + + /// The font family of text object `id`'s first run. + fn text_family(s: &Session, id: u64) -> String { + text(&s.doc().unwrap().doc, NodeId(id)).unwrap().runs[0].style.font_family.clone() + } } #[cfg(test)] diff --git a/crates/ui-egui/src/find_font.rs b/crates/ui-egui/src/find_font.rs index 23340af8c5..5155542e01 100644 --- a/crates/ui-egui/src/find_font.rs +++ b/crates/ui-egui/src/find_font.rs @@ -182,6 +182,22 @@ mod tests { assert_eq!(fonts(&mut app)[0]["family"], "Source Sans 3"); } + /// Change All leaves locked text in its font, and the status bar says so. + #[test] + fn change_all_says_when_locked_text_keeps_its_font() { + 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("text.create", &json!({"x": 10, "y": 40, "text": "Hi", "font": "Missing Family"})).unwrap(); + app.session.execute("object.lock", &json!({})).unwrap(); + open(&mut app); + let d = app.ui.dialog.clone().unwrap(); + let from = fonts(&mut app)[0].clone(); + let r = change(&mut app, &d, &from, true).unwrap(); + assert_eq!(r["runs"], 0, "{r}"); + assert_eq!(fonts(&mut app)[0]["family"], "Missing Family"); + assert_eq!(app.ui.status, "some text is hidden or locked, so its font wasn't replaced"); + } + #[test] fn find_in_folder_puts_the_missing_fonts_dialog_in_find_fonts_place() { let dir = std::fs::canonicalize(vectorcraft_testkit::temp_dir("find-font-folder")).unwrap(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 2ddd7fd2b6..84ef4701b0 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4091,6 +4091,7 @@ axis Both И двете @msg no such perspective grid preset няма такава предварителна настройка за перспективна мрежа @msg no such recent file няма такъв скорошен файл @msg no such recent font няма такъв скорошен шрифт +@msg some text is hidden or locked, so its font wasn't replaced част от текста е скрит или заключен, затова шрифтът му не беше заменен @msg no such saved selection няма такава запазена селекция @msg no such segment няма такъв сегмент @msg no such spine point няма такава точка от оста diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 1c0416e677..bf9005f9a0 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4480,6 +4480,7 @@ imageTrace Outline Obris @msg no such perspective grid preset nema takvog predloška mreže perspektive @msg no such recent file nema takve nedavne datoteke @msg no such recent font nema takvog nedavnog fonta +@msg some text is hidden or locked, so its font wasn't replaced dio teksta je skriven ili zaključan, pa njegov font nije zamijenjen @msg no such saved selection nema takvog sačuvanog odabira @msg no such segment nema takvog segmenta @msg no such spine point nema takve tačke kičme diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 34722c0b16..42e560cc97 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5170,6 +5170,7 @@ @msg no such perspective grid preset no existeix aquest preajust de quadrícula de perspectiva @msg no such recent file no existeix aquest fitxer recent @msg no such recent font no existeix aquest tipus de lletra recent +@msg some text is hidden or locked, so its font wasn't replaced part del text està amagat o bloquejat, així que el seu tipus de lletra no s'ha substituït @msg no such saved selection no existeix aquesta selecció desada @msg no such segment no existeix aquest segment @msg no such spine point no existeix aquest punt de l’espina diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index b5fbc21540..ee3a5acb1b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4517,6 +4517,7 @@ imageTrace Outline Obrys @msg no such perspective grid preset takové přednastavení perspektivní mřížky neexistuje @msg no such recent file takový nedávný soubor neexistuje @msg no such recent font takové nedávné písmo neexistuje +@msg some text is hidden or locked, so its font wasn't replaced některý text je skrytý nebo zamčený, a proto jeho písmo nebylo nahrazeno @msg no such saved selection takový uložený výběr neexistuje @msg no such segment takový segment neexistuje @msg no such spine point takový bod páteře neexistuje diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 4642b364bc..2372e6020f 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4199,6 +4199,7 @@ imageTrace Outline Kontur @msg no such perspective grid preset ingen sådan perspektivgitterforudindstilling @msg no such recent file ingen sådan seneste fil @msg no such recent font ingen sådan seneste skrifttype +@msg some text is hidden or locked, so its font wasn't replaced noget tekst er skjult eller låst, så dens skrifttype blev ikke erstattet @msg no such saved selection ingen sådan gemt markering @msg no such segment intet sådant segment @msg no such subpath ingen sådan delsti diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 320960d0ef..44c46ab54b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4131,6 +4131,7 @@ theme Appearance Erscheinungsbild @msg no such perspective grid preset keine solche Vorgabe für Perspektivenraster @msg no such recent file keine solche zuletzt geöffnete Datei @msg no such recent font keine solche zuletzt verwendete Schrift +@msg some text is hidden or locked, so its font wasn't replaced einige Texte sind ausgeblendet oder gesperrt, daher wurde ihre Schrift nicht ersetzt @msg no such saved selection keine solche gespeicherte Auswahl @msg no such segment kein solches Segment @msg no such spine point kein solcher Achsenpunkt diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7dfc4f3e67..a7e06cec80 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4170,6 +4170,7 @@ theme Appearance Εμφάνιση @msg no such perspective grid preset δεν υπάρχει τέτοια προεπιλογή πλέγματος προοπτικής @msg no such recent file δεν υπάρχει τέτοιο πρόσφατο αρχείο @msg no such recent font δεν υπάρχει τέτοια πρόσφατη γραμματοσειρά +@msg some text is hidden or locked, so its font wasn't replaced κάποιο κείμενο είναι κρυφό ή κλειδωμένο, οπότε η γραμματοσειρά του δεν αντικαταστάθηκε @msg no such saved selection δεν υπάρχει τέτοια αποθηκευμένη επιλογή @msg no such segment δεν υπάρχει τέτοιο τμήμα @msg no such spine point δεν υπάρχει τέτοιο σημείο κεντρικής καμπύλης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 454557e044..c34f741d4e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4210,6 +4210,7 @@ axis Both Ambos @msg no such perspective grid preset no existe ese preajuste de cuadrícula de perspectiva @msg no such recent file no existe ese archivo reciente @msg no such recent font no existe esa fuente reciente +@msg some text is hidden or locked, so its font wasn't replaced parte del texto está oculto o bloqueado, así que su fuente no se ha reemplazado @msg no such saved selection no existe esa selección guardada @msg no such segment no existe ese segmento @msg no such spine point no existe ese punto del eje diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index ede17d2aca..2614a0929f 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4426,6 +4426,7 @@ imageTrace Outline Ääriviiva @msg no such perspective grid preset perspektiiviruudukon esiasetusta ei ole @msg no such recent file viimeisintä tiedostoa ei ole @msg no such recent font viimeisintä fonttia ei ole +@msg some text is hidden or locked, so its font wasn't replaced osa tekstistä on piilotettu tai lukittu, joten sen fonttia ei korvattu @msg no such saved selection tallennettua valintaa ei ole @msg no such segment segmenttiä ei ole @msg no such spine point selkärangan pistettä ei ole diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index fae329cc5b..b837907d65 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4207,6 +4207,7 @@ axis Both Les deux @msg no such perspective grid preset paramètre prédéfini de grille de perspective inexistant @msg no such recent file fichier récent inexistant @msg no such recent font police récente inexistante +@msg some text is hidden or locked, so its font wasn't replaced une partie du texte est masquée ou verrouillée : sa police n'a pas été remplacée @msg no such saved selection sélection enregistrée inexistante @msg no such segment segment inexistant @msg no such spine point point de la dorsale inexistant diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index eb4e09e41a..03dab8279d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4874,6 +4874,7 @@ imageTrace Outline Körvonal @msg no such perspective grid preset nincs ilyen perspektivikusrács-előbeállítás @msg no such recent file nincs ilyen legutóbbi fájl @msg no such recent font nincs ilyen legutóbbi betűtípus +@msg some text is hidden or locked, so its font wasn't replaced néhány szöveg rejtett vagy zárolt, ezért a betűtípusa nem lett lecserélve @msg no such saved selection nincs ilyen mentett kijelölés @msg no such segment nincs ilyen szakasz @msg no such spine point nincs ilyen gerincpont diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e43dc998ae..52c29b570c 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4199,6 +4199,7 @@ imageTrace Outline Kontur @msg no such perspective grid preset tidak ada preset kisi perspektif seperti itu @msg no such recent file tidak ada berkas terbaru seperti itu @msg no such recent font tidak ada font terbaru seperti itu +@msg some text is hidden or locked, so its font wasn't replaced sebagian teks tersembunyi atau terkunci, sehingga fontnya tidak diganti @msg no such saved selection tidak ada seleksi tersimpan seperti itu @msg no such segment tidak ada segmen seperti itu @msg no such subpath tidak ada subjalur seperti itu diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index eefe97ac7f..a681efadf6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4250,6 +4250,7 @@ axis Both Entrambi @msg no such perspective grid preset preset di griglia prospettica inesistente @msg no such recent file file recente inesistente @msg no such recent font font recente inesistente +@msg some text is hidden or locked, so its font wasn't replaced parte del testo è nascosta o bloccata, quindi il suo font non è stato sostituito @msg no such saved selection selezione salvata inesistente @msg no such segment segmento inesistente @msg no such spine point punto della dorsale inesistente diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 51b72cd238..8c31a2eb7f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4544,6 +4544,7 @@ swatch Bright Violet ビビッドバイオレット @msg no such perspective grid preset 指定された遠近グリッドプリセットが見つかりません @msg no such recent file 指定された最近使ったファイルが見つかりません @msg no such recent font 指定された最近使ったフォントが見つかりません +@msg some text is hidden or locked, so its font wasn't replaced 一部のテキストが非表示またはロックされているため、そのフォントは置換されませんでした @msg no such saved selection 指定された選択範囲は保存されていません @msg no such user library 指定されたユーザーライブラリが見つかりません @msg no such view 指定された表示が見つかりません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 7ae614f12b..33eae7aece 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3986,6 +3986,7 @@ font-technology Variable ცვლადი @msg no such perspective grid preset პერსპექტივის ბადის ასეთი პრესეტი არ არის @msg no such recent file ასეთი ბოლოს გახსნილი ფაილი არ არის @msg no such recent font ასეთი ბოლოდროინდელი შრიფტი არ არის +@msg some text is hidden or locked, so its font wasn't replaced ზოგიერთი ტექსტი დამალული ან ჩაკეტილია, ამიტომ მისი შრიფტი არ შეიცვალა @msg no such saved selection ასეთი შენახული მონიშვნა არ არის @msg no such segment ასეთი სეგმენტი არ არის @msg no such subpath ასეთი ქვეტრაექტორია არ არის diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 23e55a28fd..cf3254be22 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4472,6 +4472,7 @@ theme Appearance 외관 @msg no such perspective grid preset 해당 원근 격자 프리셋이 없습니다 @msg no such recent file 해당 최근 파일이 없습니다 @msg no such recent font 해당 최근 글꼴이 없습니다 +@msg some text is hidden or locked, so its font wasn't replaced 일부 텍스트가 숨겨져 있거나 잠겨 있어 해당 글꼴을 바꾸지 않았습니다 @msg no such saved selection 해당 저장된 선택 영역이 없습니다 @msg no such user library 해당 사용자 라이브러리가 없습니다 @msg no such view 해당 보기가 없습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c269832419..2801614915 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4211,6 +4211,7 @@ imageTrace Outline Contour @msg no such perspective grid preset perspectiefrastervoorinstelling bestaat niet @msg no such recent file recent bestand bestaat niet @msg no such recent font recent lettertype bestaat niet +@msg some text is hidden or locked, so its font wasn't replaced een deel van de tekst is verborgen of vergrendeld, dus het lettertype ervan is niet vervangen @msg no such saved selection opgeslagen selectie bestaat niet @msg no such segment segment bestaat niet @msg no such subpath subpad bestaat niet diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 65cb40b345..880adf64a3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4341,6 +4341,7 @@ imageTrace Outline Kontur @msg no such perspective grid preset brak takiego ustawienia siatki perspektywy @msg no such recent file brak takiego ostatniego pliku @msg no such recent font brak takiej ostatniej czcionki +@msg some text is hidden or locked, so its font wasn't replaced część tekstu jest ukryta lub zablokowana, więc jego czcionka nie została zastąpiona @msg no such saved selection brak takiego zapisanego zaznaczenia @msg no such segment brak takiego segmentu @msg no such spine point brak takiego punktu grzbietu diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8fd2b3ba5e..a4aea80cf8 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3787,6 +3787,7 @@ axis Both Ambos @msg no such perspective grid preset predefinição de grade de perspectiva inexistente @msg no such recent file arquivo recente inexistente @msg no such recent font fonte recente inexistente +@msg some text is hidden or locked, so its font wasn't replaced parte do texto está oculta ou bloqueada, então sua fonte não foi substituída @msg no such saved selection seleção salva inexistente @msg no such segment segmento inexistente @msg no such spine point ponto da coluna inexistente diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b605f92778..7ebfa3c2b3 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4246,6 +4246,7 @@ axis Both Оба @msg no such perspective grid preset такого набора сетки перспективы нет @msg no such recent file такого последнего файла нет @msg no such recent font такого последнего шрифта нет +@msg some text is hidden or locked, so its font wasn't replaced часть текста скрыта или заблокирована, поэтому её шрифт не заменён @msg no such saved selection такого сохранённого выделения нет @msg no such segment такого сегмента нет @msg no such spine point такой точки остова нет diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index df96962c15..390cc84234 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4121,6 +4121,7 @@ axis Both Båda @msg no such perspective grid preset förinställningen för perspektivrutnät finns inte @msg no such recent file den senaste filen finns inte @msg no such recent font det senaste typsnittet finns inte +@msg some text is hidden or locked, so its font wasn't replaced en del text är dold eller låst, så dess typsnitt ersattes inte @msg no such saved selection den sparade markeringen finns inte @msg no such segment segmentet finns inte @msg no such spine point ryggradspunkten finns inte diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d099149f07..b16002086b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3983,6 +3983,7 @@ axis Both Her İkisi @msg no such perspective grid preset böyle bir perspektif ızgarası ön ayarı yok @msg no such recent file böyle yeni bir dosya yok @msg no such recent font böyle yeni bir yazı tipi yok +@msg some text is hidden or locked, so its font wasn't replaced bazı metinler gizli veya kilitli olduğundan yazı tipleri değiştirilmedi @msg no such saved selection böyle kayıtlı bir seçim yok @msg no such segment öyle bir bölüm yok @msg no such subpath böyle bir alt yol yok diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 9454d2f364..d3c0cda4fc 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4234,6 +4234,7 @@ axis Both Обидві @msg no such perspective grid preset такого набору сітки перспективи немає @msg no such recent file такого останнього файлу немає @msg no such recent font такого останнього шрифту немає +@msg some text is hidden or locked, so its font wasn't replaced частина тексту прихована або заблокована, тому її шрифт не замінено @msg no such saved selection такого збереженого виділення немає @msg no such segment такого сегменту немає @msg no such spine point такої точки осі немає diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f0c495d4f9..63c0825dfb 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4523,6 +4523,7 @@ theme Appearance Giao diện @msg no such perspective grid preset không có thiết lập sẵn lưới phối cảnh như vậy @msg no such recent file không có tệp gần đây như vậy @msg no such recent font không có phông chữ gần đây như vậy +@msg some text is hidden or locked, so its font wasn't replaced một số văn bản bị ẩn hoặc bị khóa nên phông chữ của chúng không được thay thế @msg no such saved selection không có vùng chọn đã lưu như vậy @msg no such segment không có đoạn như vậy @msg no such spine point không có điểm trục như vậy diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 61defa0208..5fe6e81923 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4261,6 +4261,7 @@ imageTrace Outline 轮廓 @msg no such perspective grid preset 没有此透视网格预设 @msg no such recent file 没有此最近使用的文件 @msg no such recent font 没有此最近使用的字体 +@msg some text is hidden or locked, so its font wasn't replaced 部分文字已隐藏或锁定,因此其字体未被替换 @msg no such saved selection 没有此已保存的选区 @msg no such user library 没有此用户库 @msg no such view 没有此视图 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 423e780b0d..e352fe56be 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2201,6 +2201,7 @@ file-filter Templates 範本 @msg no such perspective grid preset 沒有這個透視格點預設集 @msg no such recent file 沒有這個最近使用的檔案 @msg no such recent font 沒有這個最近使用的字體 +@msg some text is hidden or locked, so its font wasn't replaced 部分文字已隱藏或鎖定,因此其字體未被取代 @msg no such saved selection 沒有這個已儲存的選取範圍 @msg no such user library 沒有這個使用者資料庫 @msg no such view 沒有這個檢視 From 65b6606d90e4e3e2250cc0877f20e93def849ffa Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 31 Oct 2026 18:46:57 +0700 Subject: [PATCH 36/98] Part of #3229: setting type in a font or style that isn't available warns Squashed from #4866 onto main. --- crates/engine/src/cmd/create.rs | 14 +++++- crates/engine/src/cmd/fonts.rs | 78 +++++++++++++++++++++++++++++-- crates/engine/src/cmd/textedit.rs | 16 ++++++- crates/engine/src/cmd/typecmd.rs | 21 ++++++++- 4 files changed, 118 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index a943ee10dd..f414182470 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> { "Create Text", [], None, - "{x, y, text, rotation?: deg (about x, y), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color?, keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", + "{x, y, text, rotation?: deg (about x, y), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color?, keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", has_doc, text_create ), @@ -739,7 +739,17 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> { let made = insert_look_turned(s, "Type", NodeKind::Text(Box::new(t)), NewArt::plain(Appearance::default()), None, true, bbox_turn(rotation))?; let id = made.get("id").and_then(Value::as_u64).map(NodeId); let made = super::typecmd::with_bounds(s, id, made); - Ok(super::typecmd::with_overflow(s, id.as_slice(), made)) + let mut made = super::typecmd::with_overflow(s, id.as_slice(), made); + if p.get("font").is_some() || p.get("style").is_some() { + let runs = match id.and_then(|i| s.doc().ok()?.doc.node(i)).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => t.runs.iter().map(|r| super::fonts::font_of(&r.style)).collect(), + _ => Default::default(), + }; + if let Some(w) = super::fonts::set_font_warning(runs) { + made["warning"] = json!(w); + } + } + Ok(made) } /// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 827875b206..cf3404c313 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> { "Replace Font", [], None, - "{from: {family, style?}, to: {family (any of its names; stored as the font's own family name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); text that is hidden or locked, in a hidden or locked group or layer, or on a locked artboard keeps its font → {runs, skipped?: the text objects left in the font, warning?}", + "{from: {family, style?}, to: {family (any of its names; stored as the font's own family name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); text that is hidden or locked, in a hidden or locked group or layer, or on a locked artboard keeps its font → {runs, skipped?: the text objects left in the font, warning?: text (some text was left in its font, or the style set isn't available: its type is drawn in the family's closest style)}", has_doc, replace ), @@ -318,6 +318,24 @@ pub(super) fn substitution_warning(d: &Document) -> Option<String> { /// [`substitution_warning`] for type set in `fonts`. pub(super) fn substitution_warning_of(fonts: BTreeSet<UsedFont>) -> Option<String> { + substitutions(fonts, ("were written", "was drawn")) +} + +/// The font (family, style, version) of character style `st`. +pub(super) fn font_of(st: &vectorcraft_doc::CharStyle) -> UsedFont { + (st.font_family.clone(), st.font_style.clone(), st.font_version.clone()) +} + +/// What `text.create`, `text.setStyle`, `text.setRangeStyle` and `text.replaceFont` say when the +/// fonts they set type in (`fonts`) don't draw it: the family isn't available (the fallback font +/// is drawn) or doesn't have that style (its closest style is drawn). `None` when all draw it. +pub(super) fn set_font_warning(fonts: BTreeSet<UsedFont>) -> Option<String> { + substitutions(fonts, ("are drawn", "is drawn")) +} + +/// The warning for type set in `fonts`, with the verbs `written` and `drawn` (an export's are +/// `were written` and `was drawn`). +fn substitutions(fonts: BTreeSet<UsedFont>, (written, drawn): (&str, &str)) -> Option<String> { let db = vectorcraft_text::FontDb::global(); let mut missing: Vec<String> = Vec::new(); let mut styles: BTreeSet<String> = BTreeSet::new(); @@ -325,7 +343,7 @@ pub(super) fn substitution_warning_of(fonts: BTreeSet<UsedFont>) -> Option<Strin match db.resolve(&family, &style) { None | Some((_, vectorcraft_text::FontMatch::Missing)) => missing.push(family), Some((face, vectorcraft_text::FontMatch::Style)) => { - styles.insert(format!("{family} {style} was drawn in {} {}", face.family, face.style)); + styles.insert(format!("{family} {style} {drawn} in {} {}", face.family, face.style)); } Some(_) => {} } @@ -335,14 +353,14 @@ pub(super) fn substitution_warning_of(fonts: BTreeSet<UsedFont>) -> Option<Strin let mut parts = Vec::new(); if !missing.is_empty() { parts.push(format!( - "fonts that aren't available were written in the fallback font, {}: {} (install them, or replace them with Find Font)", + "fonts that aren't available {written} in the fallback font, {}: {} (install them, or replace them with Find Font)", vectorcraft_text::FALLBACK_FAMILY, missing.join(", ") )); } if !styles.is_empty() { parts.push(format!( - "font styles that aren't available were written in the closest style: {} (pick a style the family has)", + "font styles that aren't available {written} in the closest style: {} (pick a style the family has)", styles.into_iter().collect::<Vec<_>>().join(", ") )); } @@ -426,6 +444,8 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> { let skipped = other.into_iter().filter(|id| text(d, *id).is_some_and(|t| t.runs.iter().any(|r| matches(&r.style)))).count(); (ids, skipped) }; + // The fonts the replaced runs are set in, for the warning. + let mut set = BTreeSet::new(); let n = s.edit("Replace Font", |d, _| { let mut n = 0; for id in &ids { @@ -443,6 +463,7 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> { // Another font: none of the old one's versions or Variable Font settings. r.style.font_version = None; r.style.font_axes.clear(); + set.insert(font_of(&r.style)); changed = true; n += 1; } @@ -455,9 +476,14 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> { Ok(n) })?; let mut out = json!({ "runs": n }); + let mut warnings = Vec::new(); if skipped > 0 { out["skipped"] = json!(skipped); - out["warning"] = json!(REPLACE_SKIPPED); + warnings.push(REPLACE_SKIPPED.to_string()); + } + warnings.extend(set_font_warning(set)); + if !warnings.is_empty() { + out["warning"] = json!(warnings.join("; ")); } Ok(out) } @@ -751,6 +777,48 @@ mod open_tests { assert!(warnings.contains("Source Sans 3 Black Wide was drawn in Source Sans 3"), "{out}"); } + #[test] + fn setting_type_in_a_font_it_cant_draw_warns() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let warning = |out: &Value| out["warning"].as_str().map(str::to_string); + let made = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Hi", "font": "Source Sans 3", "style": "Black Wide"})).unwrap(); + assert!(warning(&made).is_some_and(|w| w.contains("Source Sans 3 Black Wide is drawn in Source Sans 3")), "{made}"); + let gone = s.execute("text.create", &json!({"x": 10, "y": 60, "text": "Gone", "font": "No Such Font Family"})).unwrap(); + let w = warning(&gone).unwrap_or_default(); + assert!(w.contains("No Such Font Family") && w.contains(vectorcraft_text::FALLBACK_FAMILY), "{gone}"); + let plain = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Fine", "font": "Source Sans 3", "style": "Semibold"})).unwrap(); + assert_eq!(warning(&plain), None, "{plain}"); + let id = plain["id"].as_u64().unwrap(); + let set = s.execute("text.setStyle", &json!({"ids": [id], "style": "Black Wide"})).unwrap(); + assert!(warning(&set).is_some_and(|w| w.contains("Source Sans 3 Black Wide is drawn in Source Sans 3")), "{set}"); + let back = s.execute("text.setStyle", &json!({"ids": [id], "style": "Bold"})).unwrap(); + assert_eq!(warning(&back), None, "{back}"); + // Only the font the command set: the other objects' substitutes aren't its to report. + let size = s.execute("text.setStyle", &json!({"ids": [id], "size": 30})).unwrap(); + assert_eq!(warning(&size), None, "{size}"); + // Only the characters the command styles: with "Fi" in Black Wide, styling "ne" doesn't warn. + let first = s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 2, "style": "Black Wide"})).unwrap(); + assert!(warning(&first).is_some_and(|w| w.contains("Source Sans 3 Black Wide is drawn in Source Sans 3")), "{first}"); + let rest = s.execute("text.setRangeStyle", &json!({"id": id, "start": 2, "end": 4, "font": "Source Sans 3"})).unwrap(); + assert_eq!(warning(&rest), None, "{rest}"); + let range = s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 2, "font": "No Such Font Family"})).unwrap(); + assert!(warning(&range).is_some_and(|w| w.contains("No Such Font Family")), "{range}"); + let replaced = s + .execute( + "text.replaceFont", + &json!({"from": {"family": "No Such Font Family"}, "to": {"family": "Source Sans 3", "style": "Black Wide"}}), + ) + .unwrap(); + assert!(warning(&replaced).is_some_and(|w| w.contains("Source Sans 3 Black Wide is drawn in Source Sans 3")), "{replaced}"); + // Empty type: the font set is the one the next characters typed get. + let empty = s.execute("text.create", &json!({"x": 10, "y": 140, "text": ""})).unwrap()["id"].as_u64().unwrap(); + let set = s.execute("text.setStyle", &json!({"ids": [empty], "style": "Black Wide"})).unwrap(); + assert!(warning(&set).is_some_and(|w| w.contains("Black Wide")), "{set}"); + let range = s.execute("text.setRangeStyle", &json!({"id": empty, "font": "No Such Font Family"})).unwrap(); + assert!(warning(&range).is_some_and(|w| w.contains("No Such Font Family")), "{range}"); + } + #[test] fn opened_documents_have_exact_text_bounds() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index ae272f93a6..cb4a51354a 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), @@ -708,6 +708,9 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> { let t = text_ref(s, id)?; change.localize(&t, C)?; let (a, b) = range_of(p, edit::runs_len(&t.runs), C)?; + // The fonts the styled characters are set in, for the warning. + let mut set = std::collections::BTreeSet::new(); + let sets_font = change.font.is_some() || change.style.is_some(); let n = s.edit("Character", |d, _| { let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?; if a == b && !t.plain_text().is_empty() { @@ -719,10 +722,19 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> { if let Some(before) = before { protect_missing_glyphs(&before, &mut t.runs); } + if sets_font { + // Empty text has one run, which the empty range styles. + let styled = if a == b { t.runs.clone() } else { edit::slice_runs(&t.runs, a, b) }; + set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style))); + } refresh_bounds(t); Ok(t.runs.len()) })?; - Ok(json!({"id": id.0, "runs": n})) + let mut out = json!({"id": id.0, "runs": n}); + if let Some(w) = super::fonts::set_font_warning(set) { + out["warning"] = json!(w); + } + Ok(out) } /// The em a laid-out glyph's kerning and tracking are measured in, in text space: the size it diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 6d49301a8e..97ffea3561 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -123,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> { "Character", [], None, - "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, keepModel?: bool (as paint.setFill), features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}", + "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, keepModel?: bool (as paint.setFill), features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_style ), @@ -805,6 +805,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> { let range = TextRange::parse(p, C)?; let ids = text_targets(s, p, C)?; let protect = s.prefs.missing_glyph_protection && (font.is_some() || style.is_some()); + // The fonts the styled characters are set in, for the warning. + let mut set = std::collections::BTreeSet::new(); s.edit("Character", |d, _| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; @@ -846,6 +848,17 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> { if let Some(before) = before { super::textedit::protect_missing_glyphs(&before, &mut t.runs); } + if font.is_some() || style.is_some() { + // All the text: every run, the one run of empty text included. + let styled = match range { + None => t.runs.clone(), + Some(r) => { + let (a, b) = r.bytes(t); + vectorcraft_text::edit::slice_runs(&t.runs, a, b) + } + }; + set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style))); + } if let Some(j) = justify { let span = para_span(range, t); t.edit_paras(span, |pa| pa.justify = j); @@ -888,7 +901,11 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> { } Ok(()) })?; - Ok(with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))) + let mut out = with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })); + if let Some(w) = super::fonts::set_font_warning(set) { + out["warning"] = json!(w); + } + Ok(out) } /// Reshape area type's frame with `f` ([`TextObject::transform_area`], From 3a7e81e6c3d742357e90c520aa4fffdaa80e725d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 31 Oct 2026 01:20:23 -0700 Subject: [PATCH 37/98] Appearance: Add New Fill copies the selected fill row right above it With a fill row selected in the Appearance panel, Add New Fill now inserts a copy of that row directly above it and selects the new fill. Rows already above the selected one stay where they are. Before, the new fill was always a copy of the topmost fill, added at the top. The selected row belongs to the first selected object. In a mixed selection, an object that shares that row gets the copy above its own equal row; an object that does not share it gets a copy of its top fill on top, as before. With a stroke row selected, or with `ids` or `target: "contents"`, Add New Fill works as before. The panel drops rows that were Cmd- or Shift-clicked together when the active row changes outside the panel, so after Add New Fill (button, menu or shortcut) only the new fill is selected. --- crates/engine/src/cmd/appearance.rs | 42 ++++++++++-- crates/engine/src/tests/appearance.rs | 68 +++++++++++++++++++ crates/ui-egui/src/panels/appearance.rs | 11 +-- crates/ui-egui/src/panels/appearance/tests.rs | 22 ++++++ 4 files changed, 134 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index f6aecc32d8..de4885564c 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> { "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 (a copy of its top fill; type without one: its characters' fill; else the default fill)", + "{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 (a copy of its top fill; type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids` and no `target: \"contents\"`, the new fill is a copy of that row, right above it, and becomes the active row", has_doc, |s, p| add_item(s, p, true) ), @@ -470,12 +470,45 @@ fn set_active_item(s: &mut Session, p: &Value) -> Result<Value> { fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> { let ids = require_targets(s, p, if fill { "appearance.addFill" } else { "appearance.addStroke" })?; let defaults = s.paint.clone(); + // The panel's active Fill row, when the new fill goes on the selection: the new fill is a copy + // of that row, right above it in each target that has it (others get a copy of their top fill). // Where the new item went in on the active item's object, when it is an image. let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied()); + // The active row is a row of the active object's stack, which need not come first in `ids` + // (they are in paint order): match the others against it. + let anchor = active.and_then(|a| ids.iter().position(|id| *id == a)); + let selected = match (s.appearance_item(), anchor) { + (Some(index), Some(_)) if fill && p.get("ids").is_none() && p.get("id").is_none() && !targets_contents(p)? => { + ItemTarget::Item { index, explicit: false, aligned: true }.of_kind(s, true) + } + _ => ItemTarget::Top, + }; + let per: Vec<Option<usize>> = match (selected, anchor) { + (ItemTarget::Item { index, .. }, Some(k)) => { + let mut order = ids.clone(); + order.swap(0, k); + let mut per: Vec<Option<usize>> = + items_per_target(&s.doc()?.doc, &order, &[index], true).into_iter().map(|v| v.first().copied()).collect(); + per.swap(0, k); + per + } + _ => vec![None; ids.len()], + }; let mut inserted = None; + // Where the new fill went in on the active item's object, when it went above the active row. + let mut above = None; s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| { - for id in &ids { + for (id, row) in ids.iter().zip(&per) { if let Some(n) = d.node_mut(*id) { + if let Some(i) = n.appearance.item_of_kind(*row, true) + && let Some(paint) = n.appearance.paint_at(Some(i), true).cloned() + { + n.appearance.insert_item(i + 1, AppearanceItem::Fill(FillLayer::new(paint))); + if active == Some(*id) { + above = Some(i + 1); + } + continue; + } // A copy of the top fill (stroke); without one, type's characters' and otherwise the // default paint, so a new fill on a group or type shows. let paint = match (n.appearance.paint_at(None, fill), &n.kind) { @@ -509,8 +542,9 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> { } Ok(()) })?; - // The rows above the new item move up by one. - s.remap_appearance_item(&ids, |a| Some(a + usize::from(inserted.is_some_and(|k| k <= a)))); + // A fill added above the active row becomes the active row; otherwise the rows above the new + // item move up by one. + s.remap_appearance_item(&ids, |a| above.or(Some(a + usize::from(inserted.is_some_and(|k| k <= a))))); ok() } diff --git a/crates/engine/src/tests/appearance.rs b/crates/engine/src/tests/appearance.rs index d6336b86da..7d8cb2e0fb 100644 --- a/crates/engine/src/tests/appearance.rs +++ b/crates/engine/src/tests/appearance.rs @@ -288,6 +288,74 @@ fn add_fill_and_stroke_copy_the_top_ones() { assert_eq!(node(&s, id).appearance.items.len(), 3); } +/// With a fill row active, Add New Fill puts a copy of that row right above it and makes the copy +/// the active row; the rows above it stay where they are. +#[test] +fn add_fill_copies_the_active_fill_row_right_above_it() { + let mut s = session(); + let id = rect(&mut s, 100.0); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + run(&mut s, "appearance.addFill", json!({})); + run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#0000ff"})); // [red, Stroke, blue] + run(&mut s, "appearance.setActiveItem", json!({"index": 0})); + let steps = s.doc().unwrap().history.undo.len(); + run(&mut s, "appearance.addFill", json!({})); + let n = node(&s, id); + assert_eq!(n.appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "fill", "stroke", "fill"]); + let hex = |i: usize| fill_paint(&n, i).color().unwrap().to_hex(); + assert_eq!((hex(0), hex(1), hex(3)), ("#ff0000".to_string(), "#ff0000".to_string(), "#0000ff".to_string())); + assert_eq!(s.appearance_item(), Some(1), "the new fill is the active row"); + // The next edit goes to the new fill. + run(&mut s, "paint.setFill", json!({"color": "#00ff00"})); + let n = node(&s, id); + assert_eq!((fill_paint(&n, 0).color().unwrap().to_hex(), fill_paint(&n, 1).color().unwrap().to_hex()), ("#ff0000".into(), "#00ff00".into())); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 2); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(node(&s, id).appearance.items.len(), 3); + // An active stroke row, or ids, leave the new fill on top. + run(&mut s, "appearance.setActiveItem", json!({"index": 1})); + run(&mut s, "appearance.addFill", json!({})); + assert_eq!(node(&s, id).appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "stroke", "fill", "fill"]); + run(&mut s, "appearance.setActiveItem", json!({"index": 0})); + run(&mut s, "appearance.addFill", json!({"ids": [id.0]})); + assert_eq!(fill_paint(&node(&s, id), 4).color().unwrap().to_hex(), "#0000ff"); + assert_eq!(s.appearance_item(), Some(0)); +} + +/// In a mixed selection the active fill row goes above itself in the first object; an object that +/// doesn't share that row gets a copy of its top fill on top, as without an active row. +#[test] +fn add_fill_above_an_unshared_active_row_tops_the_other_objects() { + let mut s = session(); + let (a, b) = mixed_circles(&mut s, true); // a [yellow, Stroke], b [red, green, Stroke] + run(&mut s, "appearance.setActiveItem", json!({"index": 0})); + run(&mut s, "appearance.addFill", json!({})); + let (na, nb) = (node(&s, a), node(&s, b)); + assert_eq!(na.appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "fill", "stroke"]); + assert_eq!(fill_paint(&na, 1).color().unwrap().to_hex(), "#ffff00"); + assert_eq!(nb.appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "fill", "stroke", "fill"]); + assert_eq!(fill_paint(&nb, 3).color().unwrap().to_hex(), "#00ff00"); + assert_eq!(s.appearance_item(), Some(1)); +} + +/// The active row is a row of the first selected object, also when that object paints above the +/// others. +#[test] +fn add_fill_goes_above_the_active_row_of_the_first_selected_object() { + let mut s = session(); + let (a, b) = mixed_circles(&mut s, true); // a [yellow, Stroke], b [red, green, Stroke] + run(&mut s, "select.set", json!({"ids": [b.0, a.0]})); + run(&mut s, "appearance.setActiveItem", json!({"index": 1})); // b's green fill + run(&mut s, "appearance.addFill", json!({})); + let (na, nb) = (node(&s, a), node(&s, b)); + assert_eq!(nb.appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "fill", "fill", "stroke"]); + assert_eq!(fill_paint(&nb, 2).color().unwrap().to_hex(), "#00ff00"); + assert_eq!(na.appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "stroke", "fill"]); + assert_eq!(fill_paint(&na, 2).color().unwrap().to_hex(), "#ffff00"); + assert_eq!(s.appearance_item(), Some(2)); +} + #[test] fn set_item_and_remove_item() { let mut s = session(); diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 19a578137a..09df1544ac 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -88,15 +88,16 @@ pub(crate) fn select_row(app: &mut VectorcraftApp, ctx: &egui::Context, sel: Sel } let owner = app.session.active().and_then(|d| d.selection.subjects().first().copied()); set_pstate(ctx, "ap-sel", (owner, sel)); - set_pstate(ctx, "ap-multi", (owner, sel.item().into_iter().collect::<Vec<usize>>())); + set_pstate(ctx, "ap-multi", (owner, sel.item().into_iter().collect::<Vec<usize>>(), sel.item())); } /// The selected fill/stroke rows of `n` (paint order, ascending): the active row plus those -/// Cmd/Shift-clicked with it. +/// Cmd/Shift-clicked with it. When the active row has changed since (Add New Fill makes the new +/// fill active), only the active row. fn selected_items(ctx: &egui::Context, n: Option<&Node>, sel: Sel) -> Vec<usize> { let (Some(n), Sel::Item(active)) = (n, sel) else { return vec![] }; - let (owner, mut set): (Option<NodeId>, Vec<usize>) = pstate(ctx, "ap-multi"); - if owner != Some(n.id) || !set.contains(&active) { + let (owner, mut set, at): (Option<NodeId>, Vec<usize>, Option<usize>) = pstate(ctx, "ap-multi"); + if owner != Some(n.id) || at != Some(active) || !set.contains(&active) { return vec![active]; } set.retain(|i| *i < n.appearance.items.len()); @@ -130,7 +131,7 @@ fn click_item(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, sel: Sel, } }; select_row(app, ctx, Sel::Item(active)); - set_pstate(ctx, "ap-multi", (Some(n.id), set)); + set_pstate(ctx, "ap-multi", (Some(n.id), set, Some(active))); } /// An effect as the fx menu lists it: (id, label without the ellipsis, Effect-menu path). diff --git a/crates/ui-egui/src/panels/appearance/tests.rs b/crates/ui-egui/src/panels/appearance/tests.rs index 16bc55039b..dfd7ccb638 100644 --- a/crates/ui-egui/src/panels/appearance/tests.rs +++ b/crates/ui-egui/src/panels/appearance/tests.rs @@ -438,6 +438,28 @@ fn groups_show_a_contents_row_and_type_a_characters_row_in_their_slot() { assert_eq!(rows(frame(&mut app)), ["Fill:", "Characters"]); } +/// Add New Fill (button, menu or shortcut) with rows selected: the copy of the active row lands +/// right above it and is then the only selected row. +#[test] +fn add_new_fill_selects_the_new_fill_alone() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = NodeId(app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap()); + app.run("appearance.addFill", json!({})).unwrap(); // [Fill, Stroke, Fill] + let ctx = egui::Context::default(); + let n = app.session.doc().unwrap().doc.node(id).unwrap().clone(); + // Rows 0 and 1 (the stroke) Cmd-clicked together, row 0 active. + click_item(&mut app, &ctx, &n, Sel::None, 0, egui::Modifiers::NONE); + click_item(&mut app, &ctx, &n, Sel::Item(0), 1, egui::Modifiers::COMMAND); + assert_eq!(selected_items(&ctx, Some(&n), Sel::Item(0)), [0, 1]); + app.run("appearance.addFill", json!({})).unwrap(); + let n = app.session.doc().unwrap().doc.node(id).unwrap().clone(); + assert_eq!(n.appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "fill", "stroke", "fill"]); + let sel = current_sel(&app, &ctx, Some(&n)); + assert_eq!(sel, Sel::Item(1)); + assert_eq!(selected_items(&ctx, Some(&n), sel), [1]); +} + #[test] fn images_show_a_contents_row_with_new_fills_and_strokes_below_it() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); From 45b266c8056e0f9b4648efbce5c638907e08c7c1 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 31 Oct 2026 01:14:24 -0700 Subject: [PATCH 38/98] Appearance: Reduce to Basic Appearance finds the active row in the first selected object The targets are in paint order, so the first selected object is not always the first target. The active row is now matched against the first selected object wherever it is among them. --- crates/engine/src/cmd/appearance.rs | 16 ++++++++++------ crates/engine/src/tests/appearance.rs | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index df691490ff..3da1cb56c1 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -615,12 +615,16 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> { // When every object comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); // The Appearance panel's active row (a row of the first selected object's stack, and in each - // other target the row equal to it there), when the command acts on that selection. - let active = match s.appearance_item() { - Some(index) - if p.get("ids").is_none() && p.get("id").is_none() && !targets_contents(p)? && s.doc()?.selection.subjects().first() == ids.first() => - { - items_per_target(&before, &ids, &[index], true) + // other target the row equal to it there), when the command acts on that selection. The targets + // are in paint order, so the first selected object can be any of them. + let first = s.doc()?.selection.subjects().first().and_then(|f| ids.iter().position(|id| id == f)); + let active = match (s.appearance_item(), first) { + (Some(index), Some(k)) if p.get("ids").is_none() && p.get("id").is_none() && !targets_contents(p)? => { + let mut from = ids.clone(); + from.swap(0, k); + let mut rows = items_per_target(&before, &from, &[index], true); + rows.swap(0, k); + rows } _ => vec![Vec::new(); ids.len()], }; diff --git a/crates/engine/src/tests/appearance.rs b/crates/engine/src/tests/appearance.rs index 4906228129..3a56ff7f18 100644 --- a/crates/engine/src/tests/appearance.rs +++ b/crates/engine/src/tests/appearance.rs @@ -444,6 +444,25 @@ fn reduce_to_basic_keeps_the_active_row() { assert_eq!(stroke(&n, 1).width, 1.0); } +/// The active row is a row of the first selected object even when that object paints above the +/// others; another object without an equal row keeps its topmost fill. +#[test] +fn reduce_to_basic_keeps_the_active_row_of_the_first_selected_object() { + let mut s = session(); + let a = rect(&mut s, 50.0); + run(&mut s, "appearance.addFill", json!({})); + run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#0000ff"})); // [Fill, Stroke, Fill blue] + let b = rect(&mut s, 200.0); + run(&mut s, "appearance.addFill", json!({})); + run(&mut s, "appearance.setItem", json!({"index": 0, "color": "#00ff00"})); + run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#ff0000"})); // [Fill green, Stroke, Fill red] + run(&mut s, "select.set", json!({"ids": [b.0, a.0]})); + run(&mut s, "appearance.setActiveItem", json!({"index": 0})); + run(&mut s, "appearance.reduceToBasic", json!({})); + assert_eq!(fill_paint(&node(&s, b), 0).color().unwrap().to_hex(), "#00ff00"); + assert_eq!(fill_paint(&node(&s, a), 0).color().unwrap().to_hex(), "#0000ff"); +} + /// How many steps there are to undo and to redo, and whether the document has unsaved changes. fn history(s: &Session) -> (usize, usize, bool) { let st = s.doc().unwrap(); From 3d345ff95bb280adac3d24555c7dba07c481af44 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 30 Oct 2026 23:07:50 -0700 Subject: [PATCH 39/98] Geometry: a perspective point that overflows maps to nothing instead of infinity Homography::apply checked that w is in front of the horizon but not the quotients, so a point with w just above 1e-9 and a large x or y, or a NaN w, mapped to an infinite or NaN point. apply now returns None unless the mapped point is finite. inverse, conjugated and affine_at return None when their result has an entry that is not finite. --- crates/geom/src/projective.rs | 59 ++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/crates/geom/src/projective.rs b/crates/geom/src/projective.rs index 349ddb80f2..6264df67d3 100644 --- a/crates/geom/src/projective.rs +++ b/crates/geom/src/projective.rs @@ -40,7 +40,8 @@ impl Homography { [m[0][0], m[0][1], m[0][2], m[1][0], m[1][1], m[1][2], m[2][0], m[2][1], m[2][2]] } - /// Map a point; `None` when it lands on or beyond the horizon (w ≤ 0). + /// Map a point; `None` when it lands on or beyond the horizon (w ≤ 0), or when it does not map + /// to a finite page point. pub fn apply(&self, p: Point) -> Option<Point> { let m = &self.0; let x = m[0][0] * p.x + m[0][1] * p.y + m[0][2]; @@ -49,7 +50,8 @@ impl Homography { if w <= 1e-9 || !x.is_finite() || !y.is_finite() { return None; } - Some(Point::new(x / w, y / w)) + let q = Point::new(x / w, y / w); + q.is_finite().then_some(q) } pub fn det(&self) -> f64 { @@ -58,7 +60,8 @@ impl Homography { + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]) } - /// Inverse. + /// Inverse; `None` when the map is not invertible or its inverse has an entry that is not + /// finite. pub fn inverse(&self) -> Option<Self> { let m = &self.0; let d = self.det(); @@ -77,6 +80,9 @@ impl Homography { out[r][k] = v / d; } } + if out.iter().flatten().any(|v| !v.is_finite()) { + return None; + } // H⁻¹·(x, y, 1) = (u, v, 1)/w_H, so points in front of the camera keep w > 0. Some(Self(out)) } @@ -94,16 +100,18 @@ impl Homography { } /// `self` seen through `a`: the map `a ∘ self ∘ a⁻¹` (what `self` becomes when everything it - /// maps from and to is moved by `a`). `None` when `a` isn't invertible. + /// maps from and to is moved by `a`). `None` when `a` isn't invertible or the result has an + /// entry that is not finite. pub fn conjugated(&self, a: Affine) -> Option<Self> { if a.determinant().abs() < 1e-15 { return None; } - Some(Self::from_affine(a).then_after(self).then_after(&Self::from_affine(a.inverse()))) + let c = Self::from_affine(a).then_after(self).then_after(&Self::from_affine(a.inverse())); + c.0.iter().flatten().all(|v| v.is_finite()).then_some(c) } /// The affine map that matches `self` to first order at `p` (its derivative there), `None` - /// beyond the horizon. + /// beyond the horizon or when the derivative is not finite. pub fn affine_at(&self, p: Point) -> Option<Affine> { let m = &self.0; let w = m[2][0] * p.x + m[2][1] * p.y + m[2][2]; @@ -111,7 +119,8 @@ impl Homography { // d(x/w) = (dx − (x/w)·dw) / w, and the same for y. let j = |r: usize, c: usize| (m[r][c] - [q.x, q.y][r] * m[2][c]) / w; let lin = Affine::new([j(0, 0), j(1, 0), j(0, 1), j(1, 1), 0.0, 0.0]); - Some(Affine::translate(q.to_vec2()) * lin * Affine::translate(-p.to_vec2())) + let out = Affine::translate(q.to_vec2()) * lin * Affine::translate(-p.to_vec2()); + out.is_finite().then_some(out) } /// The bounding box of the image of `r` (lines stay lines: its corners bound it), `None` when @@ -175,6 +184,42 @@ mod tests { } } + #[test] + fn a_point_that_overflows_maps_to_nothing() { + // w = 1e-8 is in front of the horizon, but 1e305 / 1e-8 overflows. + let near = Homography([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1e-8]]); + assert_eq!(near.apply(Point::new(1e305, 0.0)), None); + assert_eq!(near.apply(Point::new(0.0, -1e305)), None); + assert_eq!(near.map_rect_bbox(Rect::new(0.0, 0.0, 1e305, 1.0)), None); + assert!(near.apply(Point::new(2.0, 3.0)).unwrap().distance(Point::new(2e8, 3e8)) < 1e-6); + // A NaN w is not caught by the horizon test. + let nan = Homography([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, f64::NAN]]); + assert_eq!(nan.apply(Point::new(1.0, 1.0)), None); + } + + #[test] + fn an_inverse_that_overflows_is_none() { + let big = Homography([[1e300, 0.0, 0.0], [0.0, 1e300, 0.0], [0.0, 0.0, 1e-310]]); + assert_eq!(big.inverse(), None); + assert!(h().inverse().is_some()); + } + + #[test] + fn a_derivative_that_overflows_is_none() { + // The point maps to 1e300, but the derivative there is 1e302 / 1e-8. + let steep = Homography([[1e302, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1e-8]]); + let p = Point::new(1e-10, 0.0); + assert!(steep.apply(p).is_some()); + assert_eq!(steep.affine_at(p), None); + } + + #[test] + fn a_conjugate_that_overflows_is_none() { + let far = Homography([[1.0, 0.0, 1e200], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + assert_eq!(far.conjugated(Affine::scale(1e150)), None); + assert!(far.conjugated(Affine::scale(2.0)).is_some()); + } + #[test] fn rect_bbox_is_the_corners_box() { let b = h().map_rect_bbox(Rect::new(0.0, 0.0, 10.0, 20.0)).unwrap(); From 021fa4a8460cd5f28e4b70d1ad682897ba4eace0 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 30 Oct 2026 15:01:22 -0700 Subject: [PATCH 40/98] Shortcuts: Shift+Comma and Shift+Period follow their key positions only with Shift alone The key-position match for Shift+Comma and Shift+Period now applies only when Shift is the only modifier. Cmd/Ctrl+Shift and Alt+Shift presses at those keys keep their logical key, so the type-size shortcuts on German-like layouts behave as before. The typing test also checks that no paint command runs while text is edited. --- crates/ui-egui/src/shortcuts.rs | 4 ++++ crates/ui-egui/src/shortcuts/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 504e84b2d7..8b8355b074 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -736,6 +736,10 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, accept: impl FnMut(&H for e in &mut i.events { if let egui::Event::Key { key, physical_key: Some(physical), modifiers, pressed: true, .. } = e && modifiers.shift + && !modifiers.command + && !modifiers.ctrl + && !modifiers.alt + && !modifiers.mac_cmd && matches!(*physical, Key::Comma | Key::Period) && *key != *physical && let (Some(logical), Some(position)) = (craft_keymap::Key::from_egui(*key), craft_keymap::Key::from_egui(*physical)) diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 6bce0cc6fb..56e552b316 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -1184,12 +1184,37 @@ fn plain_and_shifted_paint_keys_run_once_on_us_and_german_like_input() { (Key::Colon, Key::Period, Modifiers::SHIFT, ":"), (Key::Semicolon, Key::Comma, Modifiers::SHIFT, ";"), ] { + let journal_before = app.session.journal.len(); frame(&mut app, press(key, physical_key, mods, typed)); assert_eq!(crate::panels::current_paints(&app), paints_before, "typing {typed} keeps fill and stroke"); + assert!( + app.session.journal.since(journal_before).iter().all(|(command, _)| !command.starts_with("paint.")), + "typing {typed} runs no paint command" + ); } assert_eq!(text_and_layers(&mut app, id).0, "Hi,.<>:;"); } +#[test] +fn modified_german_like_punctuation_keeps_text_size() { + 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("text.create", &json!({"x": 20, "y": 50, "text": "Hi", "size": 12})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + for (key, physical_key) in [(Key::Colon, Key::Period), (Key::Semicolon, Key::Comma)] { + let modifiers = Modifiers::COMMAND | Modifiers::SHIFT; + frame( + &mut app, + vec![ + egui::Event::ModifiersChanged(modifiers), + egui::Event::Key { key, physical_key: Some(physical_key), pressed: true, repeat: false, modifiers }, + ], + ); + let range = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); + assert_eq!(range["runs"][0]["style"]["size"], json!(12.0), "{key:?} keeps the text size"); + } +} + #[test] fn explicit_logical_shifted_binding_wins_over_position_alias() { let mut app = remembered_paint_app(); From db38e2e1144dd07e419f4668c35e70d41726e721 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 30 Oct 2026 23:27:44 -0700 Subject: [PATCH 41/98] Perspective: a projection that a transform overflows is scaled down or removed When moving type or a symbol instance in perspective makes the conjugated projection overflow, Homography::conjugated now forms the product from factors scaled to a largest entry of 1 and returns it at that scale, which maps every point to the same place. When even the scaled result is not invertible, the transform removes the projection and the art is drawn flat, instead of keeping the old projection while the flat art moves. --- crates/doc/src/node/tests.rs | 29 ++++++++++++++++++++++++ crates/doc/src/perspective.rs | 8 ++++--- crates/geom/src/projective.rs | 42 ++++++++++++++++++++++++++++++----- 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index dc241841fa..b4cc7b0722 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -339,6 +339,35 @@ fn transform_group_recurses() { assert_eq!(child.appearance.stroke_width(), 2.0); } +/// A symbol instance in perspective keeps its picture moved by a transform, and loses its +/// projection when no invertible map of finite numbers draws the moved picture. +#[test] +fn a_projection_follows_the_transform_or_is_removed() { + use vectorcraft_geom::{Homography, Point}; + let instance = |h: Homography| { + let mut n = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::IDENTITY }); + n.extras_mut().perspective = + Some(Box::new(crate::PerspectiveAttachment { projection: Some(h.to_array()), ..crate::PerspectiveAttachment::new("left", 0.0) })); + n + }; + let projection = |n: &Node| n.extras().perspective.as_deref().and_then(|p| p.projection); + // A move by 10 scaled by 1e307: moved by a scale by 10, the product overflows unless scaled down. + let big = Homography([[1e307, 0.0, 1e308], [0.0, 1e307, 0.0], [0.0, 0.0, 1e307]]); + let mut n = instance(big); + n.transform(Affine::scale(10.0), false); + let p = Point::new(3.0, 7.0); + assert!(n.projection().unwrap().apply(p).unwrap().distance(Point::new(103.0, 7.0)) < 1e-9); + // A move by 1e200 moved by a scale by 1e150 sends every point to about (1e350, 0). + let far = Homography([[1.0, 0.0, 1e200], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + let mut n = instance(far); + n.transform(Affine::scale(1e150), false); + assert_eq!(projection(&n), None); + // A transform that is not invertible leaves the projection as it is. + let mut n = instance(far); + n.transform(Affine::scale(0.0), false); + assert_eq!(projection(&n), Some(far.to_array())); +} + #[test] fn unplaced_gradients_pin_only_when_a_refit_would_differ() { use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint}; diff --git a/crates/doc/src/perspective.rs b/crates/doc/src/perspective.rs index 4de3823dba..6ec18bcfa2 100644 --- a/crates/doc/src/perspective.rs +++ b/crates/doc/src/perspective.rs @@ -58,12 +58,14 @@ impl Node { } } - /// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too). + /// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too). An + /// invertible `a` that the projection cannot follow removes it, and the art is drawn flat. pub(crate) fn transform_projection(&mut self, a: vectorcraft_geom::Affine) { if let Some(p) = self.extras_if_any().and_then(|e| e.perspective.as_deref_mut()) - && let Some(h) = p.homography().and_then(|h| h.conjugated(a)) + && let Some(h) = p.homography() + && a.determinant().abs() >= 1e-15 { - p.projection = Some(h.to_array()); + p.projection = h.conjugated(a).map(|h| h.to_array()); } } } diff --git a/crates/geom/src/projective.rs b/crates/geom/src/projective.rs index 6264df67d3..e7754c1312 100644 --- a/crates/geom/src/projective.rs +++ b/crates/geom/src/projective.rs @@ -100,14 +100,31 @@ impl Homography { } /// `self` seen through `a`: the map `a ∘ self ∘ a⁻¹` (what `self` becomes when everything it - /// maps from and to is moved by `a`). `None` when `a` isn't invertible or the result has an - /// entry that is not finite. + /// maps from and to is moved by `a`). When the product overflows, it is formed from factors + /// scaled to a largest entry of 1 and returned at that scale (the same map). `None` when `a` + /// isn't invertible, or when the product overflows and the scaled result is not invertible. pub fn conjugated(&self, a: Affine) -> Option<Self> { if a.determinant().abs() < 1e-15 { return None; } - let c = Self::from_affine(a).then_after(self).then_after(&Self::from_affine(a.inverse())); - c.0.iter().flatten().all(|v| v.is_finite()).then_some(c) + let (to, from) = (Self::from_affine(a), Self::from_affine(a.inverse())); + let c = to.then_after(self).then_after(&from); + if c.is_finite() { + return Some(c); + } + let c = to.normalized()?.then_after(&self.normalized()?).then_after(&from.normalized()?).normalized()?; + (c.det().abs() > 1e-15).then_some(c) + } + + fn is_finite(&self) -> bool { + self.0.iter().flatten().all(|v| v.is_finite()) + } + + /// `self` divided by its largest entry magnitude, a positive scale that maps every point to + /// the same place; `None` when an entry is not finite or every entry is 0. + fn normalized(&self) -> Option<Self> { + let k = self.0.iter().flatten().fold(0.0_f64, |k, v| k.max(v.abs())); + (self.is_finite() && k > 0.0).then(|| Self(self.0.map(|row| row.map(|v| v / k)))) } /// The affine map that matches `self` to first order at `p` (its derivative there), `None` @@ -214,10 +231,25 @@ mod tests { } #[test] - fn a_conjugate_that_overflows_is_none() { + fn a_conjugate_that_overflows_is_scaled_down() { + // A move by 10, scaled by 1e307: seen through a scale by 10 it moves by 100, but the + // product's corner entry is 1e309. + let big = Homography([[1e307, 0.0, 1e308], [0.0, 1e307, 0.0], [0.0, 0.0, 1e307]]); + let t = Affine::scale(10.0); + let c = big.conjugated(t).unwrap(); + assert!(c.0.iter().flatten().all(|v| v.is_finite() && v.abs() <= 1.0)); + assert_eq!(Homography::from_array(c.to_array()), Some(c)); + for p in [Point::new(3.0, 7.0), Point::new(-4.0, 1.5)] { + assert!(c.apply(t * p).unwrap().distance(t * big.apply(p).unwrap()) < 1e-9); + assert!(c.apply(p).unwrap().distance(p + kurbo::Vec2::new(100.0, 0.0)) < 1e-9); + } + // A move by 1e200 seen through a scale by 1e150 sends every point to about (1e350, 0), + // which no invertible map of finite numbers draws. let far = Homography([[1.0, 0.0, 1e200], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); assert_eq!(far.conjugated(Affine::scale(1e150)), None); assert!(far.conjugated(Affine::scale(2.0)).is_some()); + // A product that does not overflow is kept as it is. + assert_eq!(far.conjugated(Affine::scale(2.0)).unwrap().0[0][2], 2e200); } #[test] From 55108efef4675cf09bf1e0b4d1731192d7bce2fc Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 30 Oct 2026 15:24:53 -0700 Subject: [PATCH 42/98] Toolbar: the paint-chip tips name Shift+, and Shift+. instead of < and > The Color and Gradient tips now read "Color (, or Shift+,)" and "Gradient (. or Shift+.)". The shortcuts follow the key positions, so on layouts where Shift at those keys types another character the tips name the keys to press. The 25 catalogs with these rows and the toolbar accessibility test follow. --- crates/ui-egui/src/i18n/bg.tsv | 4 ++-- crates/ui-egui/src/i18n/ca.tsv | 4 ++-- crates/ui-egui/src/i18n/cs.tsv | 4 ++-- crates/ui-egui/src/i18n/da.tsv | 4 ++-- crates/ui-egui/src/i18n/de.tsv | 4 ++-- crates/ui-egui/src/i18n/el.tsv | 4 ++-- crates/ui-egui/src/i18n/es.tsv | 4 ++-- crates/ui-egui/src/i18n/fi.tsv | 4 ++-- crates/ui-egui/src/i18n/fr.tsv | 4 ++-- crates/ui-egui/src/i18n/hu.tsv | 4 ++-- crates/ui-egui/src/i18n/id.tsv | 4 ++-- crates/ui-egui/src/i18n/it.tsv | 4 ++-- crates/ui-egui/src/i18n/ja.tsv | 4 ++-- crates/ui-egui/src/i18n/ka.tsv | 4 ++-- crates/ui-egui/src/i18n/ko.tsv | 4 ++-- crates/ui-egui/src/i18n/nl.tsv | 4 ++-- crates/ui-egui/src/i18n/pl.tsv | 4 ++-- crates/ui-egui/src/i18n/pt-br.tsv | 4 ++-- crates/ui-egui/src/i18n/ru.tsv | 4 ++-- crates/ui-egui/src/i18n/sv.tsv | 4 ++-- crates/ui-egui/src/i18n/tr.tsv | 4 ++-- crates/ui-egui/src/i18n/uk.tsv | 4 ++-- crates/ui-egui/src/i18n/vi.tsv | 4 ++-- crates/ui-egui/src/i18n/zh-hans.tsv | 4 ++-- crates/ui-egui/src/i18n/zh-hant.tsv | 4 ++-- crates/ui-egui/src/toolbar.rs | 4 +++- crates/ui-egui/src/toolbar/tests.rs | 2 +- 27 files changed, 54 insertions(+), 52 deletions(-) diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 84ef4701b0..706c4a5f65 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -495,7 +495,7 @@ font-technology Variable Променливи Collect in New Layer Събиране в нов слой Collect Links in a Separate Folder Събиране на връзките в отделна папка Color Цвят - Color (, or <) Цвят (, или <) + Color (, or Shift+,) Цвят (, или Shift+,) Color Adjustments Цветови корекции Color bars Цветови скали Color blindness – Deuteranopia-type Цветна слепота – тип девтеранопия @@ -1235,7 +1235,7 @@ font-technology Variable Променливи GPL palettes keep solid colours only, as RGB. Палитрите GPL запазват само плътни цветове като RGB. GPU Performance Производителност на графичния процесор Gradient Градиент - Gradient (. or >) Градиент (. или >) + Gradient (. or Shift+.) Градиент (. или Shift+.) Gradient across stroke Градиент напречно на щриха Gradient along stroke Градиент по дължината на щриха Gradient and Mesh Resolution: Разделителна способност на градиент и мрежа: diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 42e560cc97..597eb81828 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -708,7 +708,7 @@ Collect in New Layer Recull en una capa nova Collect Links in a Separate Folder Recull els enllaços en una carpeta a part Color Color - Color (, or <) Color (, o <) + Color (, or Shift+,) Color (, o Maj+,) Color Adjustments Ajustos de color Color bars Barres de color Color blindness – Deuteranopia-type Daltonisme – tipus deuteranòpsia @@ -1690,7 +1690,7 @@ GPU Performance Rendiment de la GPU Grab bag Calaix de sastre Gradient Degradat - Gradient (. or >) Degradat (. o >) + Gradient (. or Shift+.) Degradat (. o Maj+.) Gradient across stroke Degradat transversal al traç Gradient along stroke Degradat al llarg del traç Gradient and Mesh Resolution: Resolució de degradat i malla: diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ee3a5acb1b..b1539f4d85 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1209,12 +1209,12 @@ lighting Light Světlo to set spacing and orientation nastaví rozestupy a orientaci Apply Použít Change Screen Mode (F) Změnit režim obrazovky (F) - Color (, or <) Barva (, nebo <) + Color (, or Shift+,) Barva (, nebo Shift+,) Draw Behind Kreslit za Draw Inside Kreslit dovnitř Draw Normal Kreslit normálně Expand: Rozložit: - Gradient (. or >) Přechod (. nebo >) + Gradient (. or Shift+.) Přechod (. nebo Shift+.) H: V: Modify Úpravy Navigate Pohled diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 2372e6020f..e1f760e1f0 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1127,12 +1127,12 @@ lighting Light Lys to set spacing and orientation for at angive afstand og orientering Apply Anvend Change Screen Mode (F) Skift skærmtilstand (F) - Color (, or <) Farve (, eller <) + Color (, or Shift+,) Farve (, eller Skift+,) Draw Behind Tegn bagved Draw Inside Tegn indeni Draw Normal Tegn normalt Expand: Udvid: - Gradient (. or >) Gradient (. eller >) + Gradient (. or Shift+.) Gradient (. eller Skift+.) H: H: Modify Ændr Navigate Navigér diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 44c46ab54b..bcfc78c073 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -487,7 +487,7 @@ font-technology Variable Variabel Collect in New Layer In neuer Ebene sammeln Collect Links in a Separate Folder Verknüpfungen in separatem Ordner sammeln Color Farbe - Color (, or <) Farbe (, oder <) + Color (, or Shift+,) Farbe (, oder Shift+,) Color Adjustments Farbkorrekturen Color bars Farbkontrollstreifen Color blindness – Deuteranopia-type Farbenblindheit – Deuteranopie @@ -1259,7 +1259,7 @@ file-filter Templates Vorlagen GPL palettes keep solid colours only, as RGB. GPL-Paletten enthalten nur einfarbige Farben, als RGB. GPU Performance GPU-Leistung Gradient Verlauf - Gradient (. or >) Verlauf (. oder >) + Gradient (. or Shift+.) Verlauf (. oder Shift+.) Gradient across stroke Verlauf quer zur Kontur Gradient along stroke Verlauf entlang der Kontur Gradient and Mesh Resolution: Auflösung für Verläufe und Gitter: diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index a7e06cec80..8305782fa4 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -470,7 +470,7 @@ font-technology Variable Μεταβλητές Collect in New Layer Συγκέντρωση σε νέο επίπεδο Collect Links in a Separate Folder Συγκέντρωση συνδεδεμένων αρχείων σε ξεχωριστό φάκελο Color Χρώμα - Color (, or <) Χρώμα (, ή <) + Color (, or Shift+,) Χρώμα (, ή Shift+,) Color Adjustments Ρυθμίσεις χρώματος Color bars Χρωματικές μπάρες Color blindness – Deuteranopia-type Αχρωματοψία – τύπου δευτερανοπίας @@ -1199,7 +1199,7 @@ font-technology Variable Μεταβλητές GPL palettes keep solid colours only, as RGB. Οι παλέτες GPL διατηρούν μόνο συμπαγή χρώματα σε RGB. GPU Performance Απόδοση GPU Gradient Ντεγκραντέ - Gradient (. or >) Ντεγκραντέ (. ή >) + Gradient (. or Shift+.) Ντεγκραντέ (. ή Shift+.) Gradient across stroke Ντεγκραντέ εγκάρσια στο περίγραμμα Gradient along stroke Ντεγκραντέ κατά μήκος του περιγράμματος Gradient and Mesh Resolution: Ανάλυση ντεγκραντέ και πλέγματος: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c34f741d4e..cd071b0ae1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -509,7 +509,7 @@ font-technology Variable Variables Collect in New Layer Reunir en capa nueva Collect Links in a Separate Folder Reunir vínculos en una carpeta aparte Color Color - Color (, or <) Color (, o <) + Color (, or Shift+,) Color (, o Shift+,) Color Adjustments Ajustes de color Color bars Barras de color Color blindness – Deuteranopia-type Daltonismo – tipo deuteranopía @@ -1272,7 +1272,7 @@ font-technology Variable Variables GPL palettes keep solid colours only, as RGB. Las paletas GPL solo conservan colores sólidos, en RGB. GPU Performance Rendimiento de GPU Gradient Degradado - Gradient (. or >) Degradado (. o >) + Gradient (. or Shift+.) Degradado (. o Shift+.) Gradient across stroke Degradado a lo ancho del trazo Gradient along stroke Degradado a lo largo del trazo Gradient and Mesh Resolution: Resolución de degradados y mallas: diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 2614a0929f..1d34418d98 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1187,12 +1187,12 @@ lighting Light Valo to set spacing and orientation asettaaksesi välit ja suunnan Apply Käytä Change Screen Mode (F) Vaihda näyttötilaa (F) - Color (, or <) Väri (, tai <) + Color (, or Shift+,) Väri (, tai Shift+,) Draw Behind Piirrä taakse Draw Inside Piirrä sisään Draw Normal Piirrä normaalisti Expand: Laajenna: - Gradient (. or >) Liukuväri (. tai >) + Gradient (. or Shift+.) Liukuväri (. tai Shift+.) H: K: Modify Muokkaa Navigate Siirry diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b837907d65..5e1c40015e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -510,7 +510,7 @@ font-technology Variable Variables Collect in New Layer Rassembler dans un nouveau calque Collect Links in a Separate Folder Rassembler les liens dans un dossier distinct Color Couleur - Color (, or <) Couleur (, ou <) + Color (, or Shift+,) Couleur (, ou Maj+,) Color Adjustments Réglages de couleur Color bars Barres de couleurs Color blindness – Deuteranopia-type Daltonisme – type deutéranopie @@ -1272,7 +1272,7 @@ font-technology Variable Variables GPL palettes keep solid colours only, as RGB. Les palettes GPL ne conservent que les couleurs unies, en RVB. GPU Performance Performances GPU Gradient Dégradé - Gradient (. or >) Dégradé (. ou >) + Gradient (. or Shift+.) Dégradé (. ou Maj+.) Gradient across stroke Dégradé sur la largeur du contour Gradient along stroke Dégradé le long du contour Gradient and Mesh Resolution: Résolution des dégradés et des filets : diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 03dab8279d..cc72129c0a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -679,7 +679,7 @@ font-technology Variable Változó Collect in New Layer Összegyűjtés új rétegbe Collect Links in a Separate Folder Csatolások gyűjtése külön mappába Color Szín - Color (, or <) Szín (, vagy <) + Color (, or Shift+,) Szín (, vagy Shift+,) Color Adjustments Színkorrekciók Color bars Színsávok Color blindness – Deuteranopia-type Színvakság – deuteranópia @@ -1590,7 +1590,7 @@ file-filter Templates Sablonok GPU Performance GPU-teljesítmény Grab bag Vegyes Gradient Színátmenet - Gradient (. or >) Színátmenet (. vagy >) + Gradient (. or Shift+.) Színátmenet (. vagy Shift+.) Gradient across stroke Színátmenet a körvonalon keresztben Gradient along stroke Színátmenet a körvonal mentén Gradient and Mesh Resolution: Színátmenet és háló felbontása: diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 52c29b570c..34ca64d532 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1127,12 +1127,12 @@ lighting Light Cahaya to set spacing and orientation untuk mengatur jarak dan orientasi Apply Terapkan Change Screen Mode (F) Ganti mode layar (F) - Color (, or <) Warna (, atau <) + Color (, or Shift+,) Warna (, atau Shift+,) Draw Behind Gambar di belakang Draw Inside Gambar di dalam Draw Normal Gambar normal Expand: Perluas: - Gradient (. or >) Gradien (. atau >) + Gradient (. or Shift+.) Gradien (. atau Shift+.) H: H: Modify Ubah Navigate Navigasi diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a681efadf6..7eabb61750 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -509,7 +509,7 @@ font-technology Variable Variabili Collect in New Layer Raccogli in un nuovo livello Collect Links in a Separate Folder Raccogli i collegamenti in una cartella separata Color Colore - Color (, or <) Colore (, o <) + Color (, or Shift+,) Colore (, o Shift+,) Color Adjustments Regolazioni colore Color bars Barre colore Color blindness – Deuteranopia-type Daltonismo – tipo deuteranopia @@ -1282,7 +1282,7 @@ file-filter Templates Modelli GPL palettes keep solid colours only, as RGB. Le palette GPL conservano solo i colori pieni, in RGB. GPU Performance Prestazioni GPU Gradient Sfumatura - Gradient (. or >) Sfumatura (. o >) + Gradient (. or Shift+.) Sfumatura (. o Shift+.) Gradient across stroke Sfumatura trasversale alla traccia Gradient along stroke Sfumatura lungo la traccia Gradient and Mesh Resolution: Risoluzione sfumature e trame: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8c31a2eb7f..b08889efda 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3364,7 +3364,7 @@ gradient-kind Radial 円形 Client-side (.html) クライアントサイド (.html) Cmd Cmd Collect Links in a Separate Folder リンクを別フォルダーに収集 - Color (, or <) カラー (, または <) + Color (, or Shift+,) カラー (, または Shift+,) Color Group カラーグループ Color Library カラーライブラリ Color Management カラーマネジメント @@ -3459,7 +3459,7 @@ file-filter Templates テンプレート General › Placement › X 一般 › 位置 › X General › Thumbnails 一般 › サムネール Global Process Color, CMYK グローバルプロセスカラー、CMYK - Gradient (. or >) グラデーション (. または >) + Gradient (. or Shift+.) グラデーション (. または Shift+.) Graph Data グラフデータ Graph Type グラフの種類 Graph Design グラフのデザイン diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 33eae7aece..208ff506be 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1061,12 +1061,12 @@ lighting Light სინათლე to set spacing and orientation — ინტერვალისა და ორიენტაციის დასაყენებლად Apply გამოყენება Change Screen Mode (F) ეკრანის რეჟიმის შეცვლა (F) - Color (, or <) ფერი (, ან <) + Color (, or Shift+,) ფერი (, ან Shift+,) Draw Behind უკან ხატვა Draw Inside შიგნით ხატვა Draw Normal ჩვეულებრივი ხატვა Expand: გაშლა: - Gradient (. or >) გრადიენტი (. ან >) + Gradient (. or Shift+.) გრადიენტი (. ან Shift+.) H: სიმ.: Modify შეცვლა Navigate ნავიგაცია diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index cf3254be22..6be4d08703 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3353,7 +3353,7 @@ gradient-kind Radial 방사형 Client-side (.html) 클라이언트 측 (.html) Cmd Cmd Collect Links in a Separate Folder 링크를 별도 폴더에 모으기 - Color (, or <) 색상 (, 또는 <) + Color (, or Shift+,) 색상 (, 또는 Shift+,) Color Group 색상 그룹 Color Library 색상 라이브러리 Color Management 색상 관리 @@ -3448,7 +3448,7 @@ file-filter Templates 템플릿 General › Placement › X 일반 › 배치 › X General › Thumbnails 일반 › 축소판 Global Process Color, CMYK 글로벌 프로세스 색상, CMYK - Gradient (. or >) 그레이디언트 (. 또는 >) + Gradient (. or Shift+.) 그레이디언트 (. 또는 Shift+.) Graph Data 그래프 데이터 Graph Type 그래프 유형 Graph Design 그래프 디자인 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 2801614915..6078c314c9 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1141,12 +1141,12 @@ lighting Light Licht to set spacing and orientation om de tussenruimte en de oriëntatie in te stellen Apply Toepassen Change Screen Mode (F) Schermmodus wisselen (F) - Color (, or <) Kleur (, of <) + Color (, or Shift+,) Kleur (, of Shift+,) Draw Behind Achter tekenen Draw Inside Binnen tekenen Draw Normal Normaal tekenen Expand: Uitvouwen: - Gradient (. or >) Verloop (. of >) + Gradient (. or Shift+.) Verloop (. of Shift+.) H: H: Modify Wijzigen Navigate Navigeren diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 880adf64a3..63dd77d7d6 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1140,12 +1140,12 @@ lighting Light Światło to set spacing and orientation aby ustawić odstęp i orientację Apply Zastosuj Change Screen Mode (F) Zmień tryb ekranu (F) - Color (, or <) Kolor (, lub <) + Color (, or Shift+,) Kolor (, lub Shift+,) Draw Behind Rysuj pod spodem Draw Inside Rysuj wewnątrz Draw Normal Rysuj normalnie Expand: Rozszerz: - Gradient (. or >) Gradient (. lub >) + Gradient (. or Shift+.) Gradient (. lub Shift+.) H: W: Modify Modyfikuj Navigate Nawigacja diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a4aea80cf8..94508e9659 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4330,7 +4330,7 @@ axis Both Ambos Client-side (.html) Lado do Cliente (.html) Cmd Cmd Collect Links in a Separate Folder Reunir Links em uma Pasta Separada - Color (, or <) Cor (, ou <) + Color (, or Shift+,) Cor (, ou Shift+,) Color bars Barras de cores Color Group Grupo de Cores Color Library Biblioteca de Cores @@ -4403,7 +4403,7 @@ axis Both Ambos General › Placement › X Geral › Posicionamento › X General › Thumbnails Geral › Miniaturas Global Process Color, CMYK Cor de Processo Global, CMYK - Gradient (. or >) Gradiente (. ou >) + Gradient (. or Shift+.) Gradiente (. ou Shift+.) Graph Data Dados do Gráfico Graph Type Tipo de Gráfico New Design Novo Design diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7ebfa3c2b3..37e6eb061e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -507,7 +507,7 @@ font-technology Variable Вариативные Collect in New Layer Собрать в новый слой Collect Links in a Separate Folder Собрать связи в отдельную папку Color Цвет - Color (, or <) Цвет (, или <) + Color (, or Shift+,) Цвет (, или Shift+,) Color Adjustments Настройки цвета Color bars Цветные полосы Color blindness – Deuteranopia-type Дальтонизм — дейтеранопия @@ -1280,7 +1280,7 @@ file-filter Templates Шаблоны GPL palettes keep solid colours only, as RGB. Палитры GPL сохраняют только сплошные цвета в RGB. GPU Performance Производительность GPU Gradient Градиент - Gradient (. or >) Градиент (. или >) + Gradient (. or Shift+.) Градиент (. или Shift+.) Gradient across stroke Градиент поперёк обводки Gradient along stroke Градиент вдоль обводки Gradient and Mesh Resolution: Разрешение градиентов и сеток: diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 390cc84234..fbed797583 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -497,7 +497,7 @@ font-technology Variable Variabla Collect in New Layer Samla i nytt lager Collect Links in a Separate Folder Samla länkar i en separat mapp Color Färg - Color (, or <) Färg (, eller <) + Color (, or Shift+,) Färg (, eller Shift+,) Color Adjustments Färgjusteringar Color bars Färgstaplar Color blindness – Deuteranopia-type Färgblindhet – deuteranopi @@ -1237,7 +1237,7 @@ font-technology Variable Variabla GPL palettes keep solid colours only, as RGB. GPL-paletter sparar bara enfärgade färger, som RGB. GPU Performance GPU-prestanda Gradient Gradient - Gradient (. or >) Gradient (. eller >) + Gradient (. or Shift+.) Gradient (. eller Shift+.) Gradient across stroke Gradient tvärs över konturen Gradient along stroke Gradient längs konturen Gradient and Mesh Resolution: Upplösning för gradienter och nät: diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b16002086b..aabe5c6452 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -491,7 +491,7 @@ font-technology Variable Değişken Collect in New Layer Yeni Katmanda Topla Collect Links in a Separate Folder Bağlantıları Ayrı Bir Klasörde Toplayın Color Renk - Color (, or <) Renk (, veya <) + Color (, or Shift+,) Renk (, veya Shift+,) Color Adjustments Renk Ayarlamaları Color bars Renk çubukları Color blindness – Deuteranopia-type Renk körlüğü – Döteranopi tipi @@ -1214,7 +1214,7 @@ font-technology Variable Değişken GPL palettes keep solid colours only, as RGB. GPL paletleri yalnızca düz renkleri RGB olarak tutar. GPU Performance GPU Performansı Gradient Degrade - Gradient (. or >) Gradyan (. veya >) + Gradient (. or Shift+.) Gradyan (. veya Shift+.) Gradient across stroke Kontur boyunca gradyan Gradient along stroke Kontur boyunca gradyan Gradient and Mesh Resolution: Gradyan ve Mesh Çözünürlüğü: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d3c0cda4fc..b3553b0445 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -503,7 +503,7 @@ font-technology Variable Варіативні Collect in New Layer Зібрати у новий шар Collect Links in a Separate Folder Зібрати зв’язки в окрему папку Color Колір - Color (, or <) Колір (, або <) + Color (, or Shift+,) Колір (, або Shift+,) Color Adjustments Налаштування кольору Color bars Кольорові смуги Color blindness – Deuteranopia-type Дальтонізм - дейтеранопія @@ -1276,7 +1276,7 @@ file-filter Templates Шаблони GPL palettes keep solid colours only, as RGB. Палітри GPL зберігають лише суцільні кольори RGB. GPU Performance Продуктивність GPU Gradient Градієнт - Gradient (. or >) Градієнт (. або >) + Gradient (. or Shift+.) Градієнт (. або Shift+.) Gradient across stroke Градієнт упоперек обведення Gradient along stroke Градієнт вздовж обведення Gradient and Mesh Resolution: Роздільна здатність градієнтів і сіток: diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 63c0825dfb..4bb1fd1226 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -591,7 +591,7 @@ font-technology Variable Biến thiên Collect in New Layer Thu thập vào lớp mới Collect Links in a Separate Folder Thu thập liên kết vào thư mục riêng Color Màu - Color (, or <) Màu (, hoặc <) + Color (, or Shift+,) Màu (, hoặc Shift+,) Color Adjustments Điều chỉnh màu sắc Color bars Thanh màu Color blindness – Deuteranopia-type Mù màu – loại Deuteranopia @@ -1490,7 +1490,7 @@ file-filter Templates Mẫu GPU Performance Hiệu suất GPU Grab bag Tổng hợp Gradient Chuyển sắc - Gradient (. or >) Chuyển sắc (. hoặc >) + Gradient (. or Shift+.) Chuyển sắc (. hoặc Shift+.) Gradient across stroke Chuyển sắc ngang qua nét Gradient along stroke Chuyển sắc dọc theo nét Gradient and Mesh Resolution: Độ phân giải chuyển sắc và lưới: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 5fe6e81923..bd495d05d6 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -455,7 +455,7 @@ Collect in New Layer 收集到新图层 Collect Links in a Separate Folder 将链接收集到单独的文件夹 Color 颜色 - Color (, or <) 颜色 (, 或 <) + Color (, or Shift+,) 颜色 (, 或 Shift+,) Color Adjustments 色彩调整 Color bars 色彩条 Color blindness – Deuteranopia-type 色盲 – 绿色盲型 @@ -1220,7 +1220,7 @@ file-filter Templates 模板 GPL palettes keep solid colours only, as RGB. GPL 调板仅保留为 RGB 的纯色。 GPU Performance GPU 性能 Gradient 渐变 - Gradient (. or >) 渐变 (. 或 >) + Gradient (. or Shift+.) 渐变 (. 或 Shift+.) Gradient across stroke 横跨描边的渐变 Gradient along stroke 沿描边的渐变 Gradient and Mesh Resolution: 渐变和网格分辨率: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e352fe56be..f69992edd9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -471,7 +471,7 @@ font-technology Variable 可變 Collect in New Layer 收集至新圖層 Collect Links in a Separate Folder 將連結收集到獨立的資料夾 Color 顏色 - Color (, or <) 顏色 (, 或 <) + Color (, or Shift+,) 顏色 (, 或 Shift+,) Color Adjustments 色彩調整 Color bars 色彩條 Color blindness – Deuteranopia-type 色盲 – 綠色盲型 @@ -1246,7 +1246,7 @@ file-filter Templates 範本 GPL palettes keep solid colours only, as RGB. GPL 調色盤僅保留純色,並以 RGB 儲存。 GPU Performance GPU 效能 Gradient 漸層 - Gradient (. or >) 漸層 (. 或 >) + Gradient (. or Shift+.) 漸層 (. 或 Shift+.) Gradient across stroke 橫跨筆畫的漸層 Gradient along stroke 沿筆畫的漸層 Gradient and Mesh Resolution: 漸層與網格解析度: diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 02efbfd4c3..33a2eb10b8 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -486,7 +486,9 @@ fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, cols: usiz ui.add_space((ui.available_width() - 3.0 * chip - 2.0 * gap) / 2.0); ui.spacing_mut().item_spacing.x = gap; let mut clicked = None; - for (tip, cmd) in [("Color (, or <)", "paint.lastColor"), ("Gradient (. or >)", "paint.lastGradient"), ("None (/)", "paint.none")] { + for (tip, cmd) in + [("Color (, or Shift+,)", "paint.lastColor"), ("Gradient (. or Shift+.)", "paint.lastGradient"), ("None (/)", "paint.none")] + { let (r, resp) = ui.allocate_exact_size(vec2(chip, chip), Sense::click()); match cmd { "paint.lastColor" => widgets::paint_chip(ui, r, &Paint::solid(app.session.last_solid)), diff --git a/crates/ui-egui/src/toolbar/tests.rs b/crates/ui-egui/src/toolbar/tests.rs index e8e3f49776..a1a29f7757 100644 --- a/crates/ui-egui/src/toolbar/tests.rs +++ b/crates/ui-egui/src/toolbar/tests.rs @@ -109,7 +109,7 @@ fn screen_readers_name_the_tool_buttons_and_the_active_tool() { let name = |id| tool_info(id).map(tip).unwrap_or_default(); assert_eq!(role_and_state(&tree, &name("pen")), (Role::Button, Some(Toggled::True))); assert_eq!(role_and_state(&tree, &name("selection")), (Role::Button, Some(Toggled::False))); - for label in ["Toggle single/double column", "Drag to float the toolbar", "Color (, or <)", "Gradient (. or >)", "None (/)"] { + for label in ["Toggle single/double column", "Drag to float the toolbar", "Color (, or Shift+,)", "Gradient (. or Shift+.)", "None (/)"] { assert_eq!(role_and_state(&tree, label), (Role::Button, None), "{label}"); } } From 9f644523030a1331c6ad34372e4ec62c04a3c959 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 30 Oct 2026 19:35:32 -0700 Subject: [PATCH 43/98] Perspective Selection: Snap to Grid keeps a Shift-constrained move on its axis or diagonal perspective.move takes an optional constrain: [dx, dy], a plane-space direction. For an in-plane move, Snap to Grid's correction is projected onto that direction, so a move the Shift key constrains to a plane axis or a 45-degree diagonal stays on that line when an object edge lies near a gridline. The Perspective Selection tool sends the direction for Shift moves and copies. Moves without the param behave as before. --- crates/engine/src/cmd/distortcmds.rs | 2 +- .../engine/src/cmd/distortcmds/perspective.rs | 18 +++- crates/engine/src/tests/persp_select.rs | 83 +++++++++++++++++++ crates/tools/src/distort/perspective.rs | 6 ++ crates/tools/src/distort/perspective/tools.rs | 11 ++- docs/mcp.md | 4 +- 6 files changed, 117 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 3da43957c8..e224692792 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> { "Move in Perspective", [], None, - "{ids?|id?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid)} slide objects within their plane (unattached objects attach to `plane`/the active plane first); snapping lands the nearer edge of the objects' joint bounds on a gridline within a quarter cell; Transform Again repeats it", + "{ids?|id?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid), constrain?: [dx,dy] (nonzero plane-space direction; keeps snap correction on that line for in-plane moves)} slide objects within their plane (unattached objects attach to `plane`/the active plane first); snapping pulls the nearer edge of the objects' joint bounds toward a gridline within a quarter cell, along `constrain` when supplied; Transform Again repeats it", has_doc, persp_move ), diff --git a/crates/engine/src/cmd/distortcmds/perspective.rs b/crates/engine/src/cmd/distortcmds/perspective.rs index 8481770274..023fa95c23 100644 --- a/crates/engine/src/cmd/distortcmds/perspective.rs +++ b/crates/engine/src/cmd/distortcmds/perspective.rs @@ -284,12 +284,28 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> { } let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok(); let (perp, copy) = (switch_or(p, "perpendicular", false, C)?, switch_or(p, "copy", false, C)?); + let constrain = match p.get("constrain").filter(|v| !v.is_null()) { + Some(v) => { + let direction = v.as_array().filter(|v| v.len() == 2).and_then(|v| Some((v.first()?.as_f64()?, v.get(1)?.as_f64()?))); + let (x, y) = direction.ok_or_else(|| bad(C, "constrain must be two finite numbers, not both zero"))?; + if !x.is_finite() || !y.is_finite() || (x == 0.0 && y == 0.0) { + return Err(bad(C, "constrain must be two finite numbers, not both zero")); + } + let scale = x.abs().max(y.abs()); + let (x, y) = (x / scale, y / scale); + let length = x.hypot(y); + Some(vectorcraft_geom::Vec2::new(x / length, y / length)) + } + None => None, + }; // `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false. check_switches(p, &["snap"], C)?; // Snap to Grid: a correction for the objects on the first one's plane and depth. let fix = if perp { None } else { crate::cmd::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, to)) }; let snapped = move |pl: Plane, depth: f64, dv: vectorcraft_geom::Vec2| { - dv + fix.filter(|f| f.0 == pl && f.1 == depth).map_or(vectorcraft_geom::Vec2::ZERO, |f| f.2) + dv + fix + .filter(|f| f.0 == pl && f.1 == depth) + .map_or(vectorcraft_geom::Vec2::ZERO, |f| constrain.map_or(f.2, |unit| unit * (f.2.x * unit.x + f.2.y * unit.y))) }; // In-plane: the plane-space offset under the pointer; perpendicular: the depth it reaches. let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> { diff --git a/crates/engine/src/tests/persp_select.rs b/crates/engine/src/tests/persp_select.rs index a6f912b732..b3e8341f55 100644 --- a/crates/engine/src/tests/persp_select.rs +++ b/crates/engine/src/tests/persp_select.rs @@ -129,6 +129,89 @@ fn shift_drag_moves_on_a_plane_axis() { assert_eq!(plane_pts(&s, id, Plane::Right, 0.0), before); } +/// An attached rectangle with a horizontal edge close enough to a gridline to be pulled by snapping. +fn near_gridline_session() -> (Session, NodeId) { + let (mut s, id) = persp_session(); + let g = grid(&s); + let b = bounds_of(&plane_pts(&s, id, Plane::Right, 0.0)); + let dy = (b.y0 / g.cell).round() * g.cell + g.cell * 0.1 - b.y0; + let h = g.homography(Plane::Right).unwrap(); + let from = h.apply(b.center()).unwrap(); + let to = h.apply(b.center() + Vec2::new(0.0, dy)).unwrap(); + s.execute("perspective.move", &json!({"ids": [id.0], "from": [from.x, from.y], "to": [to.x, to.y], "snap": false})).unwrap(); + s.execute("perspective.grid.snap", &json!({"on": true})).unwrap(); + (s, id) +} + +fn shift_move_near_gridline(diagonal: bool, copy: bool) { + let (mut s, id) = near_gridline_session(); + s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap(); + let before = plane_pts(&s, id, Plane::Right, 0.0); + let h = grid(&s).homography(Plane::Right).unwrap(); + let start = bounds_of(&before).center(); + let raw = if diagonal { Vec2::new(40.0, 50.0) } else { Vec2::new(80.0, 20.0) }; + let from = h.apply(start).unwrap(); + let to = h.apply(start + raw).unwrap(); + drag(&mut s, &[from, to, to], Mods { shift: true, alt: copy, ..Default::default() }); + let target = if copy { *s.doc().unwrap().selection.objects.first().unwrap() } else { id }; + assert!(!copy || target != id); + let after = plane_pts(&s, target, Plane::Right, 0.0); + for (a, b) in before.iter().zip(after) { + let d = b - *a; + if diagonal { + assert!((d.x - d.y).abs() < 1e-6, "diagonal move left its line: {d:?}"); + } else { + assert!(d.y.abs() < 1e-6, "axis move left its line: {d:?}"); + } + } + if copy { + assert_eq!(plane_pts(&s, id, Plane::Right, 0.0), before); + } +} + +#[test] +fn shift_axis_move_stays_on_axis_with_grid_snap() { + shift_move_near_gridline(false, false); +} + +#[test] +fn shift_axis_copy_stays_on_axis_with_grid_snap() { + shift_move_near_gridline(false, true); +} + +#[test] +fn shift_diagonal_move_stays_on_diagonal_with_grid_snap() { + shift_move_near_gridline(true, false); +} + +#[test] +fn shift_diagonal_copy_stays_on_diagonal_with_grid_snap() { + shift_move_near_gridline(true, true); +} + +#[test] +fn constrained_move_command_stays_on_axis_with_grid_snap() { + let (mut s, id) = near_gridline_session(); + let before = plane_pts(&s, id, Plane::Right, 0.0); + let h = grid(&s).homography(Plane::Right).unwrap(); + let start = bounds_of(&before).center(); + let from = h.apply(start).unwrap(); + let to = h.apply(start + Vec2::new(80.0, 0.0)).unwrap(); + s.execute("perspective.move", &json!({"ids": [id.0], "from": [from.x, from.y], "to": [to.x, to.y], "constrain": [1.0, 0.0]})).unwrap(); + for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) { + assert!((b.y - a.y).abs() < 1e-6, "constrained command left axis: {a:?} to {b:?}"); + } +} + +#[test] +fn constrained_move_rejects_invalid_direction() { + let (mut s, id) = near_gridline_session(); + for direction in [json!([0.0, 0.0]), json!([1.0]), json!([1.0, "bad"]), json!([null, 1.0])] { + let error = s.execute("perspective.move", &json!({"ids": [id.0], "from": [1.0, 1.0], "to": [2.0, 2.0], "constrain": direction})).unwrap_err(); + assert!(error.to_string().contains("constrain must be two finite numbers, not both zero"), "{error}"); + } +} + #[test] fn shift_drag_keeps_its_displacement_after_a_plane_key() { let (mut s, id) = persp_session(); diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index 2542e6a1a5..9052ecca40 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -1250,6 +1250,9 @@ mod tests { let endpoint = Point::new(v["to"][0].as_f64().unwrap(), v["to"][1].as_f64().unwrap()); let delta = hi.apply(endpoint).unwrap() - start; assert!((delta - expected).hypot() < 1e-6, "{plane:?}: {delta:?} != {expected:?}"); + let direction = expected / expected.hypot(); + let sent = Vec2::new(v["constrain"][0].as_f64().unwrap(), v["constrain"][1].as_f64().unwrap()); + assert!((sent - direction).hypot() < 1e-12, "{plane:?}: {sent:?} != {direction:?}"); } } } @@ -1276,16 +1279,19 @@ mod tests { t.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x, to.y).with_mods(Mods { shift: true, alt: true, ..Default::default() })); let Some(Action::Preview(_, snapped)) = shifted.last() else { panic!("{shifted:?}") }; assert_eq!(snapped["copy"], json!(true)); + assert_eq!(snapped["constrain"], json!([1.0, 0.0])); assert_ne!(snapped["to"], raw["to"]); let perpendicular = t.key(&c, ToolKey::Digit(5), Mods { shift: true, alt: true, ..Default::default() }); let Some(Action::Preview(_, depth)) = perpendicular.last() else { panic!("{perpendicular:?}") }; assert_eq!(depth["to"], raw["to"]); + assert!(depth.get("constrain").is_none()); let in_plane = t.key(&c, ToolKey::Digit(5), Mods { shift: true, alt: true, ..Default::default() }); let Some(Action::Preview(_, snapped_again)) = in_plane.last() else { panic!("{in_plane:?}") }; assert_eq!(snapped_again["to"], snapped["to"]); let released = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x, to.y)); let Some(Action::Preview(_, raw_again)) = released.last() else { panic!("{released:?}") }; assert_eq!(raw_again["to"], raw["to"]); + assert!(raw_again.get("constrain").is_none()); } #[test] diff --git a/crates/tools/src/distort/perspective/tools.rs b/crates/tools/src/distort/perspective/tools.rs index 9e9454b304..d748cb51be 100644 --- a/crates/tools/src/distort/perspective/tools.rs +++ b/crates/tools/src/distort/perspective/tools.rs @@ -167,7 +167,7 @@ impl PerspectiveSelectionTool { let SelDrag::Move { ids, from, last, perp, copy, shift, .. } = &self.drag else { return None }; let g = PerspectiveGrid::current(cx.doc); let at = ids.first().and_then(|id| g.attachment_of(cx.doc, *id)).or((g.plane != Plane::None).then_some((g.plane, g.offset(g.plane)))); - let to = if *shift && !*perp { + let constrained = if *shift && !*perp { at.and_then(|(plane, depth)| { let h = g.homography_at(plane, depth)?; let hi = h.inverse()?; @@ -176,13 +176,16 @@ impl PerspectiveSelectionTool { let angle = (delta.y.atan2(delta.x) / std::f64::consts::FRAC_PI_4).round() * std::f64::consts::FRAC_PI_4; let direction = Vec2::new(angle.cos(), angle.sin()); let distance = delta.x * direction.x + delta.y * direction.y; - h.apply(start + direction * distance).filter(|p| p.x.is_finite() && p.y.is_finite()) + h.apply(start + direction * distance).filter(|p| p.x.is_finite() && p.y.is_finite()).map(|to| (to, direction)) }) - .unwrap_or(*last) } else { - *last + None }; + let to = if let Some((to, _)) = constrained { to } else { *last }; let mut v = json!({"ids": crate::json_ids(ids), "from": [from.x, from.y], "to": [to.x, to.y], "copy": copy, "perpendicular": perp}); + if let Some((_, direction)) = constrained { + v["constrain"] = json!([direction.x, direction.y]); + } if g.plane != Plane::None { v["plane"] = json!(g.plane.id()); } diff --git a/docs/mcp.md b/docs/mcp.md index add4788c4a..164320533f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5477,7 +5477,9 @@ distance along the plane's normal), so copies, duplicates and pastes stay attach `perspectiveSelection` tool, a drag moves the selection within its plane (`perspective.move`; Alt copies), the bounding-box handles scale it in plane space (`perspective.transform`; Shift keeps proportions, Alt scales about the centre), and `press_key` `5` during a drag switches the move to perpendicular to the plane (press again to switch -back). `press_key` Escape during a move, a resize or a plane widget drag cancels it and puts the objects and the +back). Holding Shift during an in-plane move constrains the displacement to a plane axis or 45° diagonal; `perspective.move` +passes that plane-space direction as `constrain: [dx, dy]`, so Snap to Grid adjusts the move only along that line. +`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. From 375b413ba6aa3d27fdef6f95346072f28940be20 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 30 Oct 2026 23:46:02 -0700 Subject: [PATCH 44/98] Perspective: a projection that a transform overflows keeps a w row of 1 When conjugating a projection overflows, the scaled-down result is now divided by the largest entry in its w row. A valid conjugate is no longer dropped by the determinant cutoff at the arbitrary scale, and every point of a kept projection passes the horizon test in apply instead of none. --- crates/geom/src/projective.rs | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/geom/src/projective.rs b/crates/geom/src/projective.rs index e7754c1312..94831fb5cb 100644 --- a/crates/geom/src/projective.rs +++ b/crates/geom/src/projective.rs @@ -101,8 +101,10 @@ impl Homography { /// `self` seen through `a`: the map `a ∘ self ∘ a⁻¹` (what `self` becomes when everything it /// maps from and to is moved by `a`). When the product overflows, it is formed from factors - /// scaled to a largest entry of 1 and returned at that scale (the same map). `None` when `a` - /// isn't invertible, or when the product overflows and the scaled result is not invertible. + /// scaled to a largest entry of 1 and returned divided by the largest magnitude in its `w` row + /// (the same map, with a `w` row whose largest entry is 1). `None` when `a` isn't invertible, + /// or when the product overflows and the result at that scale has an entry that is not finite + /// or is not invertible. pub fn conjugated(&self, a: Affine) -> Option<Self> { if a.determinant().abs() < 1e-15 { return None; @@ -113,7 +115,12 @@ impl Homography { return Some(c); } let c = to.normalized()?.then_after(&self.normalized()?).then_after(&from.normalized()?).normalized()?; - (c.det().abs() > 1e-15).then_some(c) + let k = c.0[2].iter().fold(0.0_f64, |k, v| k.max(v.abs())); + if k <= 0.0 { + return None; + } + let c = Self(c.0.map(|row| row.map(|v| v / k))); + (c.is_finite() && c.det().abs() > 1e-15).then_some(c) } fn is_finite(&self) -> bool { @@ -237,12 +244,29 @@ mod tests { let big = Homography([[1e307, 0.0, 1e308], [0.0, 1e307, 0.0], [0.0, 0.0, 1e307]]); let t = Affine::scale(10.0); let c = big.conjugated(t).unwrap(); - assert!(c.0.iter().flatten().all(|v| v.is_finite() && v.abs() <= 1.0)); + assert!(c.is_finite()); + assert_eq!(c.0[2], [0.0, 0.0, 1.0]); assert_eq!(Homography::from_array(c.to_array()), Some(c)); for p in [Point::new(3.0, 7.0), Point::new(-4.0, 1.5)] { assert!(c.apply(t * p).unwrap().distance(t * big.apply(p).unwrap()) < 1e-9); assert!(c.apply(p).unwrap().distance(p + kurbo::Vec2::new(100.0, 0.0)) < 1e-9); } + // A move by 1 scaled by 1e307, seen through a scale by 1e7: a move by 1e7, whose + // determinant is 1 at a w row of 1 but 1e-21 at a largest entry of 1. + let one = Homography([[1e307, 0.0, 1e307], [0.0, 1e307, 0.0], [0.0, 0.0, 1e307]]); + assert!(Homography::from_array(one.to_array()).is_some()); + let c = one.conjugated(Affine::scale(1e7)).unwrap(); + assert_eq!(Homography::from_array(c.to_array()), Some(c)); + let p = Point::new(3.0, -2.0); + assert!(c.apply(p).unwrap().distance(p + kurbo::Vec2::new(1e7, 0.0)) < 1e-6); + // A scale by 1e10 seen through a scale by 1e10: still a scale by 1e10, which every point + // reaches only when w is not left at 1e-10. + let wide = Homography([[1e300, 0.0, 0.0], [0.0, 1e300, 0.0], [0.0, 0.0, 1e290]]); + let c = wide.conjugated(Affine::scale(1e10)).unwrap(); + assert_eq!(Homography::from_array(c.to_array()), Some(c)); + for p in [Point::new(1.0, 2.0), Point::new(-5.0, 0.5)] { + assert!(c.apply(p).unwrap().distance(Point::new(p.x * 1e10, p.y * 1e10)) < 1e-3); + } // A move by 1e200 seen through a scale by 1e150 sends every point to about (1e350, 0), // which no invertible map of finite numbers draws. let far = Homography([[1.0, 0.0, 1e200], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); From 64c776af4c898c2f19411966f2d0d931e2133d10 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 30 Oct 2026 16:07:18 -0700 Subject: [PATCH 45/98] Shortcuts: a bound layout candidate for Shift+Period or Shift+Comma wins over the key position --- crates/ui-egui/src/shortcuts.rs | 16 ++++++++++------ crates/ui-egui/src/shortcuts/tests.rs | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 8b8355b074..8425456b99 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -731,9 +731,10 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, accept: impl FnMut(&H let mirror = crate::shortcut_editor::current(); let dispatcher = &mut app.dispatcher; ctx.input_mut(|i| { - // Shift+Comma and Shift+Period follow their key positions when the logical character - // differs. An explicitly bound logical shifted chord takes precedence. + // Shift+Comma and Shift+Period follow their key positions when no chord for the + // logical press is bound. An explicit shifted logical binding takes precedence. for e in &mut i.events { + let input = craft_keymap::KeyInput::from_egui(e, crate::keymap::platform()); if let egui::Event::Key { key, physical_key: Some(physical), modifiers, pressed: true, .. } = e && modifiers.shift && !modifiers.command @@ -743,14 +744,17 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, accept: impl FnMut(&H && matches!(*physical, Key::Comma | Key::Period) && *key != *physical && let (Some(logical), Some(position)) = (craft_keymap::Key::from_egui(*key), craft_keymap::Key::from_egui(*physical)) + && let Some(input) = input { let mods = craft_keymap::Mods::from_egui(modifiers, crate::keymap::platform()); let logical_chord = craft_keymap::Chord::new(mods, logical); let position_chord = craft_keymap::Chord::new(mods, position); - if crate::keymap::lookup(&mirror.keymap, &logical_chord).is_some() { - *physical = *key; - } else if crate::keymap::lookup(&mirror.keymap, &position_chord).is_some() { - *key = *physical; + if !dispatcher.layout().candidates(&input).iter().any(|chord| crate::keymap::lookup(&mirror.keymap, chord).is_some()) { + if crate::keymap::lookup(&mirror.keymap, &logical_chord).is_some() { + *physical = *key; + } else if crate::keymap::lookup(&mirror.keymap, &position_chord).is_some() { + *key = *physical; + } } } } diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 56e552b316..ceb68108c7 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -1232,6 +1232,25 @@ fn explicit_logical_shifted_binding_wins_over_position_alias() { assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1); } +#[test] +fn resolved_logical_binding_wins_over_position_alias() { + for chord in [":", "Shift+;"] { + let mut app = remembered_paint_app(); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "view.grid", chord); + let journal_before = app.session.journal.len(); + frame( + &mut app, + vec![ + egui::Event::ModifiersChanged(Modifiers::SHIFT), + egui::Event::Key { key: Key::Colon, physical_key: Some(Key::Period), pressed: true, repeat: false, modifiers: Modifiers::SHIFT }, + egui::Event::Text(":".into()), + ], + ); + assert!(app.ui.view.grid, "{chord} runs Show Grid"); + assert!(app.session.journal.since(journal_before).iter().all(|(command, _)| !command.starts_with("paint.")), "{chord} runs no paint command"); + } +} + #[test] fn user_shifted_punctuation_binding_runs_once() { let mut app = remembered_paint_app(); From c15874ba6eb7135e9c92aea6123d194eef92a1e6 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 30 Oct 2026 19:58:33 -0700 Subject: [PATCH 46/98] Perspective Selection: Shift constrains each object in its own plane perspective.move takes constrain: true instead of a plane-space direction. For each object, the command snaps the move to the nearest axis or 45-degree diagonal of that object's own plane, so a selection on several planes keeps every object on a line of its plane. The tool sends the raw pointer with the flag. On a diagonal, Snap to Grid now moves along the diagonal by the nearer per-axis correction, so that edge lands on its gridline. --- crates/engine/src/cmd/distortcmds.rs | 2 +- .../engine/src/cmd/distortcmds/perspective.rs | 52 ++++++++++------- crates/engine/src/tests/persp_select.rs | 57 +++++++++++++++++-- crates/tools/src/distort/perspective.rs | 43 +++++++++++--- crates/tools/src/distort/perspective/tools.rs | 23 +++----- docs/mcp.md | 5 +- docs/target-app-parity.md | 2 +- 7 files changed, 135 insertions(+), 49 deletions(-) diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index e224692792..7a0643ca51 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> { "Move in Perspective", [], None, - "{ids?|id?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid), constrain?: [dx,dy] (nonzero plane-space direction; keeps snap correction on that line for in-plane moves)} slide objects within their plane (unattached objects attach to `plane`/the active plane first); snapping pulls the nearer edge of the objects' joint bounds toward a gridline within a quarter cell, along `constrain` when supplied; Transform Again repeats it", + "{ids?|id?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid), constrain?: bool (default false; constrain each in-plane move to its plane's nearest axis or 45° diagonal)} slide objects within their own planes (unattached objects attach to `plane`/the active plane first); snapping pulls the nearer edge of the first plane's joint bounds toward a gridline within a quarter cell while preserving the constrained direction; Transform Again repeats it", has_doc, persp_move ), diff --git a/crates/engine/src/cmd/distortcmds/perspective.rs b/crates/engine/src/cmd/distortcmds/perspective.rs index 023fa95c23..54cac360d5 100644 --- a/crates/engine/src/cmd/distortcmds/perspective.rs +++ b/crates/engine/src/cmd/distortcmds/perspective.rs @@ -284,28 +284,42 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> { } let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok(); let (perp, copy) = (switch_or(p, "perpendicular", false, C)?, switch_or(p, "copy", false, C)?); - let constrain = match p.get("constrain").filter(|v| !v.is_null()) { - Some(v) => { - let direction = v.as_array().filter(|v| v.len() == 2).and_then(|v| Some((v.first()?.as_f64()?, v.get(1)?.as_f64()?))); - let (x, y) = direction.ok_or_else(|| bad(C, "constrain must be two finite numbers, not both zero"))?; - if !x.is_finite() || !y.is_finite() || (x == 0.0 && y == 0.0) { - return Err(bad(C, "constrain must be two finite numbers, not both zero")); - } - let scale = x.abs().max(y.abs()); - let (x, y) = (x / scale, y / scale); - let length = x.hypot(y); - Some(vectorcraft_geom::Vec2::new(x / length, y / length)) - } - None => None, - }; + let constrain = switch_or(p, "constrain", false, C)?; // `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false. check_switches(p, &["snap"], C)?; // Snap to Grid: a correction for the objects on the first one's plane and depth. - let fix = if perp { None } else { crate::cmd::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, to)) }; - let snapped = move |pl: Plane, depth: f64, dv: vectorcraft_geom::Vec2| { - dv + fix - .filter(|f| f.0 == pl && f.1 == depth) - .map_or(vectorcraft_geom::Vec2::ZERO, |f| constrain.map_or(f.2, |unit| unit * (f.2.x * unit.x + f.2.y * unit.y))) + let snap_to = if constrain && !perp { + let doc = &s.doc()?.doc; + let g = grid_of(doc); + g.plane_bounds(doc, &ids) + .and_then(|(plane, depth, _)| { + let h = g.homography_at(plane, depth)?; + let start = h.inverse()?.apply(from)?; + let delta = g.plane_delta(plane, depth, from, to)?; + h.apply(start + vectorcraft_tools::distort::perspective::constrain_move_delta(delta).1) + }) + .unwrap_or(to) + } else { + to + }; + let fix = if perp { None } else { crate::cmd::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, snap_to)) }; + let snapped = move |pl: Plane, depth: f64, raw: vectorcraft_geom::Vec2| { + let (direction, dv) = + if constrain { vectorcraft_tools::distort::perspective::constrain_move_delta(raw) } else { (vectorcraft_geom::Vec2::ZERO, raw) }; + dv + fix.filter(|f| f.0 == pl && f.1 == depth).map_or(vectorcraft_geom::Vec2::ZERO, |f| { + if !constrain { + return f.2; + } + if direction.x.abs() < 1e-12 { + return vectorcraft_geom::Vec2::new(0.0, f.2.y); + } + if direction.y.abs() < 1e-12 { + return vectorcraft_geom::Vec2::new(f.2.x, 0.0); + } + let (correction, axis) = + if f.2.x == 0.0 || (f.2.y != 0.0 && f.2.y.abs() < f.2.x.abs()) { (f.2.y, direction.y) } else { (f.2.x, direction.x) }; + direction * (correction / axis) + }) }; // In-plane: the plane-space offset under the pointer; perpendicular: the depth it reaches. let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> { diff --git a/crates/engine/src/tests/persp_select.rs b/crates/engine/src/tests/persp_select.rs index b3e8341f55..c590aee5b7 100644 --- a/crates/engine/src/tests/persp_select.rs +++ b/crates/engine/src/tests/persp_select.rs @@ -197,18 +197,67 @@ fn constrained_move_command_stays_on_axis_with_grid_snap() { let start = bounds_of(&before).center(); let from = h.apply(start).unwrap(); let to = h.apply(start + Vec2::new(80.0, 0.0)).unwrap(); - s.execute("perspective.move", &json!({"ids": [id.0], "from": [from.x, from.y], "to": [to.x, to.y], "constrain": [1.0, 0.0]})).unwrap(); + s.execute("perspective.move", &json!({"ids": [id.0], "from": [from.x, from.y], "to": [to.x, to.y], "constrain": true})).unwrap(); for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) { assert!((b.y - a.y).abs() < 1e-6, "constrained command left axis: {a:?} to {b:?}"); } } #[test] -fn constrained_move_rejects_invalid_direction() { +fn diagonal_shift_snap_lands_an_edge_on_a_gridline() { let (mut s, id) = near_gridline_session(); - for direction in [json!([0.0, 0.0]), json!([1.0]), json!([1.0, "bad"]), json!([null, 1.0])] { + let g = grid(&s); + let before = plane_pts(&s, id, Plane::Right, 0.0); + let b = bounds_of(&before); + let (raw, correction) = (1..200) + .map(|n| { + let raw = Vec2::new(n as f64 * g.cell * 0.05, n as f64 * g.cell * 0.05); + (raw, g.snap_offset(b, raw) - raw) + }) + .find(|(_, fix)| fix.x.abs() < 1e-9 && fix.y.abs() > 1e-6) + .unwrap(); + let h = g.homography(Plane::Right).unwrap(); + let start = b.center(); + let from = h.apply(start).unwrap(); + let to = h.apply(start + raw).unwrap(); + s.execute("perspective.move", &json!({"ids": [id.0], "from": [from.x, from.y], "to": [to.x, to.y], "constrain": true})).unwrap(); + let after = plane_pts(&s, id, Plane::Right, 0.0); + let delta = after[0] - before[0]; + assert!((delta.x - delta.y).abs() < 1e-6, "{delta:?}"); + let edge = [b.y0 + delta.y, b.y1 + delta.y]; + assert!(edge.iter().any(|y| ((y / g.cell).round() * g.cell - y).abs() < 1e-6), "{edge:?}, correction {correction:?}"); +} + +#[test] +fn constrained_move_uses_each_objects_plane() { + let (mut s, right) = persp_session(); + let left_result = s.execute("shape.rectangle", &json!({"x": 240, "y": 380, "width": 60, "height": 60})).unwrap(); + let left = NodeId(left_result["id"].as_u64().unwrap()); + s.execute("perspective.attach", &json!({"ids": [left.0], "plane": "left"})).unwrap(); + let right_before = plane_pts(&s, right, Plane::Right, 0.0); + let left_before = plane_pts(&s, left, Plane::Left, 0.0); + let from = grid(&s).homography(Plane::Right).unwrap().apply(bounds_of(&right_before).center()).unwrap(); + let to = from + Vec2::new(70.0, 30.0); + let g = grid(&s); + for plane in [Plane::Right, Plane::Left] { + let raw = g.plane_delta(plane, 0.0, from, to).unwrap(); + assert!(raw.x.abs() > 1e-3 && raw.y.abs() > 1e-3 && (raw.x.abs() - raw.y.abs()).abs() > 1e-3, "raw {plane:?}: {raw:?}"); + } + s.execute("perspective.move", &json!({"ids": [right.0, left.0], "from": [from.x, from.y], "to": [to.x, to.y], "constrain": true, "snap": false})) + .unwrap(); + for (id, plane, before) in [(right, Plane::Right, right_before), (left, Plane::Left, left_before)] { + let after = plane_pts(&s, id, plane, 0.0); + let delta = after[0] - before[0]; + assert!(delta.x.abs() < 1e-6 || delta.y.abs() < 1e-6 || (delta.x.abs() - delta.y.abs()).abs() < 1e-6, "{plane:?}: {delta:?}"); + } +} + +#[test] +fn constrained_move_rejects_non_boolean_switch() { + let (mut s, id) = near_gridline_session(); + for direction in [json!([0.0, 0.0]), json!([1.0]), json!(1), json!("true")] { let error = s.execute("perspective.move", &json!({"ids": [id.0], "from": [1.0, 1.0], "to": [2.0, 2.0], "constrain": direction})).unwrap_err(); - assert!(error.to_string().contains("constrain must be two finite numbers, not both zero"), "{error}"); + assert!(error.to_string().contains("`constrain` must be true or false"), "{error}"); } } diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index 9052ecca40..d535b5cd2a 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -74,6 +74,14 @@ impl Plane { pub const ALL: [Plane; 3] = [Plane::Left, Plane::Right, Plane::Ground]; } +/// Return the nearest 45-degree direction of `delta` in plane space, counted from the plane's x axis, +/// and `delta` projected onto that direction. +pub fn constrain_move_delta(delta: Vec2) -> (Vec2, Vec2) { + let angle = (delta.y.atan2(delta.x) / std::f64::consts::FRAC_PI_4).round() * std::f64::consts::FRAC_PI_4; + let direction = Vec2::new(angle.cos(), angle.sin()); + (direction, direction * (delta.x * direction.x + delta.y * direction.y)) +} + /// Plane Switching widget: (plane, face quad) list and the "no plane" circle (centre, radius). pub type WidgetGeom = (Vec<(Plane, [Point; 4])>, (Point, f64)); @@ -1247,16 +1255,37 @@ mod tests { let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x, to.y).with_mods(Mods { shift: true, ..Default::default() })); let Some(Action::Preview(cmd, v)) = acts.last() else { panic!("{acts:?}") }; assert_eq!(cmd, "perspective.move"); - let endpoint = Point::new(v["to"][0].as_f64().unwrap(), v["to"][1].as_f64().unwrap()); - let delta = hi.apply(endpoint).unwrap() - start; + assert_eq!(v["to"], json!([to.x, to.y])); + let raw_delta = hi.apply(to).unwrap() - start; + let delta = constrain_move_delta(raw_delta).1; assert!((delta - expected).hypot() < 1e-6, "{plane:?}: {delta:?} != {expected:?}"); - let direction = expected / expected.hypot(); - let sent = Vec2::new(v["constrain"][0].as_f64().unwrap(), v["constrain"][1].as_f64().unwrap()); - assert!((sent - direction).hypot() < 1e-12, "{plane:?}: {sent:?} != {direction:?}"); + assert_eq!(v["constrain"], json!(true)); } } } + #[test] + fn selection_shift_preview_sends_raw_pointer_and_boolean_constraint() { + let (mut d, id) = doc_with_rect(); + grid(2).store(&mut d); + set_attachment(d.node_mut(id).unwrap(), Plane::Right, 0.0); + let mut selection = Selection::default(); + selection.add(id); + let p = paint(); + let c = cx(&d, &selection, &p); + let h = PerspectiveGrid::current(&d).homography(Plane::Right).unwrap(); + let from = d.node(id).unwrap().geometric_bounds().unwrap().center(); + let start = h.inverse().unwrap().apply(from).unwrap(); + let to = h.apply(start + Vec2::new(60.0, 12.0)).unwrap(); + let mut tool = PerspectiveSelectionTool::default(); + tool.pointer(&c, &PointerEvent::new(PointerKind::Down, from.x, from.y)); + let actions = tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x, to.y).with_mods(Mods { shift: true, ..Default::default() })); + let Some(Action::Preview(command, params)) = actions.last() else { panic!("{actions:?}") }; + assert_eq!(command, "perspective.move"); + assert_eq!(params["to"], json!([to.x, to.y])); + assert_eq!(params["constrain"], json!(true)); + } + #[test] fn selection_move_recomputes_shift_copy_and_perpendicular() { let (mut d, id) = doc_with_rect(); @@ -1279,8 +1308,8 @@ mod tests { t.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x, to.y).with_mods(Mods { shift: true, alt: true, ..Default::default() })); let Some(Action::Preview(_, snapped)) = shifted.last() else { panic!("{shifted:?}") }; assert_eq!(snapped["copy"], json!(true)); - assert_eq!(snapped["constrain"], json!([1.0, 0.0])); - assert_ne!(snapped["to"], raw["to"]); + assert_eq!(snapped["constrain"], json!(true)); + assert_eq!(snapped["to"], raw["to"]); let perpendicular = t.key(&c, ToolKey::Digit(5), Mods { shift: true, alt: true, ..Default::default() }); let Some(Action::Preview(_, depth)) = perpendicular.last() else { panic!("{perpendicular:?}") }; assert_eq!(depth["to"], raw["to"]); diff --git a/crates/tools/src/distort/perspective/tools.rs b/crates/tools/src/distort/perspective/tools.rs index d748cb51be..e791f4ef33 100644 --- a/crates/tools/src/distort/perspective/tools.rs +++ b/crates/tools/src/distort/perspective/tools.rs @@ -169,34 +169,27 @@ impl PerspectiveSelectionTool { let at = ids.first().and_then(|id| g.attachment_of(cx.doc, *id)).or((g.plane != Plane::None).then_some((g.plane, g.offset(g.plane)))); let constrained = if *shift && !*perp { at.and_then(|(plane, depth)| { - let h = g.homography_at(plane, depth)?; - let hi = h.inverse()?; - let start = hi.apply(*from)?; - let delta = hi.apply(*last)? - start; - let angle = (delta.y.atan2(delta.x) / std::f64::consts::FRAC_PI_4).round() * std::f64::consts::FRAC_PI_4; - let direction = Vec2::new(angle.cos(), angle.sin()); - let distance = delta.x * direction.x + delta.y * direction.y; - h.apply(start + direction * distance).filter(|p| p.x.is_finite() && p.y.is_finite()).map(|to| (to, direction)) + let delta = g.plane_delta(plane, depth, *from, *last)?; + Some(super::constrain_move_delta(delta).1) }) } else { None }; - let to = if let Some((to, _)) = constrained { to } else { *last }; - let mut v = json!({"ids": crate::json_ids(ids), "from": [from.x, from.y], "to": [to.x, to.y], "copy": copy, "perpendicular": perp}); - if let Some((_, direction)) = constrained { - v["constrain"] = json!([direction.x, direction.y]); + let mut v = json!({"ids": crate::json_ids(ids), "from": [from.x, from.y], "to": [last.x, last.y], "copy": copy, "perpendicular": perp}); + if *shift && !*perp { + v["constrain"] = json!(true); } if g.plane != Plane::None { v["plane"] = json!(g.plane.id()); } self.measure = at.and_then(|(pl, depth)| { let text = if *perp { - format!("dZ: {}", cx.len(g.depth_at(pl, depth, *from, to)? - depth)) + format!("dZ: {}", cx.len(g.depth_at(pl, depth, *from, *last)? - depth)) } else { - let d = g.plane_delta(pl, depth, *from, to)?; + let d = constrained.unwrap_or(g.plane_delta(pl, depth, *from, *last)?); cx.offset_label(d.x, d.y) }; - Some((to, text)) + Some((*last, text)) }); Some(Action::Preview("perspective.move".into(), v)) } diff --git a/docs/mcp.md b/docs/mcp.md index 164320533f..81681d6c2f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5477,8 +5477,9 @@ distance along the plane's normal), so copies, duplicates and pastes stay attach `perspectiveSelection` tool, a drag moves the selection within its plane (`perspective.move`; Alt copies), the bounding-box handles scale it in plane space (`perspective.transform`; Shift keeps proportions, Alt scales about the 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 the displacement to a plane axis or 45° diagonal; `perspective.move` -passes that plane-space direction as `constrain: [dx, dy]`, so Snap to Grid adjusts the move only along that line. +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. `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. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 51c0373344..2f6ee32b76 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1104,7 +1104,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur An Alt-drag with the Selection, Direct Selection, Group Selection, Rotate, Scale, Reflect or Shear tool copies the selection on its first move, and the moves after it transform those copies; the previews of the Move, Rotate, Scale, Reflect and Shear dialogs with Copy and Preview on copy the selection once in the same way (#1429). 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 on a plane constrains the displacement to the nearest plane axis or 45° diagonal; releasing Shift resumes the raw pointer position, and 5 still moves perpendicular to the plane. + 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. 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. The Layers panel's Delete Selection, Duplicate Selection, Collect in New Layer, Merge Selected, Flatten Artwork, Release to Layers (Sequence) and row drags also take time in proportion to the size of the document, and so do Cut Artboard and Duplicate Artboard with their art and each move of an Alt-drag of one artboard with the Artboard tool while Move/Copy Artwork with Artboard is on (#1429). In Outline view the renderer draws paths and compound paths from the geometry it keeps for them between frames, and it culls the members of clip groups by the bounds it keeps for them. Layers with Preview off and the outlines of Image Trace objects are drawn the same way. From c711ad42f0f2372a5bb5ad5d8783c235493a412f Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 30 Oct 2026 23:52:44 -0700 Subject: [PATCH 47/98] Perspective: a transform's overflow fallback scales each partial product When the conjugate of a projection overflows, it is now formed one factor at a time, and each partial product is divided by its largest entry magnitude. Scaling the affine factors up front let the w row underflow to 0, so a scale by 2 seen through a move by 1e308 returned None and the projection was removed, though its conjugate is finite. --- crates/geom/src/projective.rs | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/crates/geom/src/projective.rs b/crates/geom/src/projective.rs index 94831fb5cb..4b17aac60d 100644 --- a/crates/geom/src/projective.rs +++ b/crates/geom/src/projective.rs @@ -100,11 +100,11 @@ impl Homography { } /// `self` seen through `a`: the map `a ∘ self ∘ a⁻¹` (what `self` becomes when everything it - /// maps from and to is moved by `a`). When the product overflows, it is formed from factors - /// scaled to a largest entry of 1 and returned divided by the largest magnitude in its `w` row - /// (the same map, with a `w` row whose largest entry is 1). `None` when `a` isn't invertible, - /// or when the product overflows and the result at that scale has an entry that is not finite - /// or is not invertible. + /// maps from and to is moved by `a`). When the product overflows, it is formed one factor at a + /// time, each partial product scaled to a largest entry of 1, and returned divided by the + /// largest magnitude in its `w` row (the same map, with a `w` row whose largest entry is 1). + /// `None` when `a` isn't invertible, or when the product overflows and the result at that + /// scale has an entry that is not finite or is not invertible. pub fn conjugated(&self, a: Affine) -> Option<Self> { if a.determinant().abs() < 1e-15 { return None; @@ -114,7 +114,7 @@ impl Homography { if c.is_finite() { return Some(c); } - let c = to.normalized()?.then_after(&self.normalized()?).then_after(&from.normalized()?).normalized()?; + let c = to.then_after_normalized(&self.normalized()?)?.then_after_normalized(&from)?; let k = c.0[2].iter().fold(0.0_f64, |k, v| k.max(v.abs())); if k <= 0.0 { return None; @@ -134,6 +134,17 @@ impl Homography { (self.is_finite() && k > 0.0).then(|| Self(self.0.map(|row| row.map(|v| v / k)))) } + /// `self ∘ other` divided by its largest entry magnitude; when that product overflows, it is + /// formed from both factors divided by their largest entry magnitudes. `None` when the result + /// has an entry that is not finite or every entry is 0. + fn then_after_normalized(&self, other: &Self) -> Option<Self> { + let c = self.then_after(other); + if c.is_finite() { + return c.normalized(); + } + self.normalized()?.then_after(&other.normalized()?).normalized() + } + /// The affine map that matches `self` to first order at `p` (its derivative there), `None` /// beyond the horizon or when the derivative is not finite. pub fn affine_at(&self, p: Point) -> Option<Affine> { @@ -267,6 +278,16 @@ mod tests { for p in [Point::new(1.0, 2.0), Point::new(-5.0, 0.5)] { assert!(c.apply(p).unwrap().distance(Point::new(p.x * 1e10, p.y * 1e10)) < 1e-3); } + // A scale by 2 seen through a move by 1e308: p goes to 2p - 1e308, though the product's + // corner entry is 2 * -1e308 + 1e308 and the moves scaled to a largest entry of 1 leave + // their w rows at 1e-308. + let two = Homography([[2.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 1.0]]); + let c = two.conjugated(Affine::translate((1e308, 0.0))).unwrap(); + let want = [2.0, 0.0, -1e308, 0.0, 2.0, 0.0, 0.0, 0.0, 1.0]; + for (v, w) in c.to_array().iter().zip(want) { + assert!((v - w).abs() <= 1e-9 * w.abs().max(1.0), "{c:?}"); + } + assert_eq!(Homography::from_array(c.to_array()), Some(c)); // A move by 1e200 seen through a scale by 1e150 sends every point to about (1e350, 0), // which no invertible map of finite numbers draws. let far = Homography([[1.0, 0.0, 1e200], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); From 26173437b945f230c8bf45df04e14a2545387acb Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 30 Oct 2026 17:17:05 -0700 Subject: [PATCH 48/98] i18n: Bosnian paint-chip tips name Shift+, and Shift+. The Bosnian catalog still carried the Color (,) and Gradient (.) rows, which no longer match the toolbar's tips. It now translates Color (, or Shift+,) and Gradient (. or Shift+.) like the other catalogs. --- crates/ui-egui/src/i18n/bs.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index bf9005f9a0..17de837b8e 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1188,12 +1188,12 @@ lighting Light Svjetlo to set spacing and orientation za postavljanje razmaka i orijentacije Apply Primijeni Change Screen Mode (F) Promijeni način prikaza ekrana (F) - Color (,) Boja (,) + Color (, or Shift+,) Boja (, ili Shift+,) Draw Behind Crtaj iza Draw Inside Crtaj unutra Draw Normal Crtaj normalno Expand: Proširi: - Gradient (.) Gradijent (.) + Gradient (. or Shift+.) Gradijent (. ili Shift+.) H: V: Modify Izmijeni Navigate Navigacija From 9259434a89b7384b40b1345609ea51d43cd095d9 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:20:00 +0300 Subject: [PATCH 49/98] SVG export: a gradient mesh patch is cut no finer than its colour changes Each patch of a gradient mesh was cut into 8 x 8 flat quads for SVG export, Object > Expand and the other outputs that cannot shade a mesh, whatever its colours, so a patch of one colour became 64 identical pieces. Neighbouring quads are now joined into square blocks wherever the colour changes by no more than about one 8-bit step across the block; the block's outline runs through its quads' corners, and a patch that folds over itself is not joined. --- crates/doc/src/live.rs | 2 +- crates/doc/src/live/expand.rs | 5 +- crates/doc/src/live/mesh.rs | 108 +++++++++++++++++++++++++++ crates/engine/src/tests/live.rs | 12 ++- crates/svg/tests/it/main.rs | 1 + crates/svg/tests/it/mesh_export.rs | 116 +++++++++++++++++++++++++++++ docs/file-format-parity.md | 3 +- 7 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 crates/svg/tests/it/mesh_export.rs diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 258035c10f..102a614b93 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -63,7 +63,7 @@ pub use expand::{ }; #[cfg(test)] use mesh::grid_eval; -pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds}; +pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPiece, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds}; pub use warp::{WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, warp_point}; #[cfg(test)] diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index c67dbee3ee..66d20f27b6 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -293,9 +293,10 @@ pub fn is_live(n: &Node) -> bool { ) } -/// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch. +/// Mesh tessellation as flat-filled paths (no stroke): each patch cut `n`×`n`, its quads joined +/// where the colour hardly changes ([`GradientMesh::pieces`]). pub fn mesh_quad_nodes(m: &GradientMesh, n: usize) -> Vec<Node> { - m.quads(n) + m.pieces(n) .into_iter() .map(|q| { let mut ap = Appearance::default(); diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index e86eecff9d..10540266a3 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -35,6 +35,15 @@ pub struct MeshQuad { pub corners: [(Color, f32); 4], } +/// One flat-coloured piece of a mesh ([`GradientMesh::pieces`]): a closed outline through +/// tessellation points, its colour and opacity. +#[derive(Clone, Debug, PartialEq)] +pub struct MeshPiece { + pub pts: Vec<Point>, + pub color: Color, + pub opacity: f32, +} + /// Create Gradient Mesh → Appearance. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum MeshAppearance { @@ -395,6 +404,105 @@ impl GradientMesh { out } + /// Flat-coloured pieces covering the mesh, for outputs that can't shade a mesh smoothly (SVG, + /// Expand): each patch cut into `n`×`n` quads as by [`Self::quads`], then neighbouring quads + /// joined into square blocks as large as keeps the colour across each block within about one + /// 8-bit step. A patch of one colour is one piece; a patch whose colour changes a lot keeps + /// all its quads. A piece's outline runs through its quads' outer corners, so it covers what + /// they covered, and it takes the colour at its centre. + pub fn pieces(&self, n: usize) -> Vec<MeshPiece> { + let n = n.clamp(1, 64); + let quads = self.quads(n); + let cols = (self.cols as usize).max(1); + let mut out = Vec::with_capacity(quads.len()); + for (k, patch) in quads.chunks_exact(n * n).enumerate() { + let (r, c) = (k / cols, k % cols); + let side = self.block_side(r, c, n, patch); + if side == 1 { + out.extend(patch.iter().map(|q| MeshPiece { pts: q.pts.to_vec(), color: q.color, opacity: q.opacity })); + continue; + } + // Tessellation point (i, j) of the patch (0..=n each), from a quad it is a corner of. + let at = |i: usize, j: usize| -> Option<Point> { + let (qi, qj) = (i.min(n - 1), j.min(n - 1)); + let q = patch.get(qj * n + qi)?; + q.pts + .get(match (i > qi, j > qj) { + (false, false) => 0, + (true, false) => 1, + (true, true) => 2, + (false, true) => 3, + }) + .copied() + }; + for j0 in (0..n).step_by(side) { + for i0 in (0..n).step_by(side) { + let (i1, j1) = (i0 + side, j0 + side); + // Around the block, in the quads' order: along its top, down its right side, + // back along its bottom and up its left side. + let ring = (i0..i1) + .map(|i| (i, j0)) + .chain((j0..j1).map(|j| (i1, j))) + .chain((i0 + 1..=i1).rev().map(|i| (i, j1))) + .chain((j0 + 1..=j1).rev().map(|j| (i0, j))); + let Some(pts) = ring.map(|(i, j)| at(i, j)).collect::<Option<Vec<Point>>>() else { continue }; + let half = side as f64 / 2.0; + let (color, opacity) = self.color_at(r, c, (i0 as f64 + half) / n as f64, (j0 as f64 + half) / n as f64); + out.push(MeshPiece { pts, color, opacity }); + } + } + } + out + } + + /// The side, in quads, of the blocks [`Self::pieces`] joins patch (r, c) into, cut into + /// `n`×`n` `quads`: the largest divisor of `n` that keeps the colour changing by at most one + /// 8-bit step across a block. 1 (no joining) where the patch folds over itself, as the outline + /// of a block across the fold wouldn't fill it as its quads do. + fn block_side(&self, r: usize, c: usize, n: usize, quads: &[MeshQuad]) -> usize { + let corner = |dr: usize, dc: usize| self.points.get(self.idx(r + dr, c + dc)); + let (Some(p00), Some(p01), Some(p10), Some(p11)) = (corner(0, 0), corner(0, 1), corner(1, 0), corner(1, 1)) else { + return 1; + }; + // Corners all in CMYK mix in their inks ([`Self::color_at`]); the RGB they show is checked too. + let inks = [p00, p01, p10, p11].iter().all(|p| matches!(p.color, Color::Cmyk { .. })); + let channels = |p: &MeshPoint| -> Vec<f32> { + let mut v = p.color.to_rgb().to_vec(); + if inks && let Color::Cmyk { c, m, y, k } = p.color { + v.extend([c, m, y, k]); + } + v.push(p.opacity); + v + }; + let [a, b, cc, d] = [p00, p01, p10, p11].map(channels); + // A bilinear patch changes across a block of side h (0..1 of the patch) by at most + // h × (du + dv) in each channel, du and dv its largest change along each parameter. + let mut steps = 0.0f32; + for k in 0..a.len() { + let ch = |v: &[f32]| v.get(k).copied().unwrap_or_default(); + let du = (ch(&a) - ch(&b)).abs().max((ch(&cc) - ch(&d)).abs()); + let dv = (ch(&a) - ch(&cc)).abs().max((ch(&b) - ch(&d)).abs()); + steps = steps.max((du + dv) * 255.0); + } + if !steps.is_finite() { + return 1; + } + let side = (1..=n).rev().find(|&s| n.is_multiple_of(s) && (n / s) as f32 >= steps).unwrap_or(1); + if side > 1 { + // Every quad turning the same way: the patch doesn't fold, so a block's outline + // encloses just what its quads cover. + let turn = |q: &MeshQuad| { + let [a, b, c, d] = q.pts; + (b - a).cross(c - a) + (c - a).cross(d - a) + }; + let first = quads.first().map(turn).unwrap_or_default(); + if !quads.iter().all(|q| turn(q) * first > 0.0) { + return 1; + } + } + side + } + /// The mesh's outer boundary as a closed path. pub fn outline(&self) -> PathData { if !self.is_valid() { diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index ebe275bcec..a3b9f132b5 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -1020,10 +1020,15 @@ fn mesh_expand_to_flat_pieces() { let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); sel(&mut s, &[a]); s.execute("object.mesh.create", &json!({"rows": 1, "cols": 2})).unwrap(); + // The first patch shades from black to white; the second is one colour. + for (index, color) in [(0, "#000000"), (3, "#000000"), (1, "#ffffff"), (2, "#ffffff"), (4, "#ffffff"), (5, "#ffffff")] { + s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": index, "color": color})).unwrap(); + } s.execute("object.mesh.expand", &json!({})).unwrap(); let g = node(&s, a); assert!(matches!(g.kind, NodeKind::Group { .. })); - assert_eq!(g.children().unwrap().len(), 2 * 64); + // 8 × 8 pieces for the shaded patch, one for the flat one. + assert_eq!(g.children().unwrap().len(), 64 + 1); assert!(g.children().unwrap().iter().all(|c| c.id != NodeId(0))); } @@ -1093,9 +1098,10 @@ fn live_objects_round_trip_through_native_format() { fn svg_export_expands_live_objects() { let s = live_doc(); let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default()); - // 4 blend objects + envelope content + 4 patches × 64 mesh pieces. + // 4 blend objects + envelope content + a piece for each of the 4 mesh patches (the mesh is + // white, which its highlight leaves white, so a patch is one piece rather than 64 alike). let paths = vectorcraft_testkit::svg_shapes(&svg); - assert!(paths >= 4 + 1 + 256, "{paths}"); + assert!(paths >= 4 + 1 + 4, "{paths}"); assert!(vectorcraft_svg::import(&svg).is_ok()); } diff --git a/crates/svg/tests/it/main.rs b/crates/svg/tests/it/main.rs index e769c6d987..3e554a3977 100644 --- a/crates/svg/tests/it/main.rs +++ b/crates/svg/tests/it/main.rs @@ -22,6 +22,7 @@ mod import_text; mod import_units; mod interactivity; mod mask_options; +mod mesh_export; mod non_scaling_stroke; mod options; mod prop_svg; diff --git a/crates/svg/tests/it/mesh_export.rs b/crates/svg/tests/it/mesh_export.rs new file mode 100644 index 0000000000..60c5dcbb08 --- /dev/null +++ b/crates/svg/tests/it/mesh_export.rs @@ -0,0 +1,116 @@ +//! Gradient meshes in SVG export: SVG has no mesh paint, so a mesh is written as flat-coloured +//! pieces, each patch cut finely enough that neighbouring pieces differ by about one 8-bit +//! colour step at most, and no finer: a patch of one colour is one piece, not 64 alike. + +use vectorcraft_color::Color; +use vectorcraft_doc::live::{GradientMesh, MeshAppearance}; +use vectorcraft_doc::{Document, Node, NodeKind}; +use vectorcraft_geom::{Rect, shapes}; +use vectorcraft_svg::{ExportOptions, export}; +use vectorcraft_testkit::raster::{Image, channel_diff, render_artboard}; + +/// A 200 × 200 pt document with a `rows` × `cols` mesh over (20, 20)–(180, 180), each point +/// coloured by `color(row, col)`. +fn doc(rows: u32, cols: u32, color: impl Fn(usize, usize) -> Color) -> Document { + let mut m = GradientMesh::for_path(&shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 180.0)), rows, cols, Color::BLACK, MeshAppearance::Flat, 0.0) + .expect("a mesh"); + for r in 0..=rows as usize { + for c in 0..=cols as usize { + let i = m.idx(r, c); + m.points[i].color = color(r, c); + } + } + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::new(id, NodeKind::Mesh(m))).unwrap(); + d +} + +/// Filled shapes in the SVG. +fn pieces(svg: &str) -> usize { + ["<polygon", "<rect", "<path"].iter().map(|e| svg.matches(e).count()).sum() +} + +/// The exported SVG rendered by resvg on white at 1 px/pt. +fn resvg_render(svg: &str) -> Image { + let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("parse"); + let mut pm = resvg::tiny_skia::Pixmap::new(200, 200).unwrap(); + pm.fill(resvg::tiny_skia::Color::WHITE); + resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm.as_mut()); + Image { width: 200, height: 200, rgba: pm.data().to_vec() } +} + +/// Compare the SVG as resvg draws it with the canvas, over the pixels `inside` the mesh (the +/// canvas grows a mesh's outline by half a pixel, so its edge is left out): at most `bad` of them +/// differ by more than `tol` in a channel. +fn assert_looks_alike(d: &Document, svg: &str, inside: impl Fn(f64, f64) -> bool, tol: u8, bad: f64) { + let (mine, canvas) = (resvg_render(svg), render_artboard(d)); + let (mut n, mut off, mut worst) = (0usize, 0usize, 0u8); + for y in 0..200 { + for x in 0..200 { + if !inside(x as f64 + 0.5, y as f64 + 0.5) { + continue; + } + n += 1; + let c = channel_diff(mine.pixel(x, y), canvas.pixel(x, y)); + worst = worst.max(c); + if c > tol { + off += 1; + } + } + } + assert!(n > 0 && (off as f64) <= bad * n as f64, "{off} of {n} pixels differ by more than {tol} (worst {worst})"); +} + +/// Inside the mesh square, two pixels in from its edge. +fn in_square(x: f64, y: f64) -> bool { + (22.0..178.0).contains(&x) && (22.0..178.0).contains(&y) +} + +#[test] +fn a_patch_of_one_colour_is_one_piece() { + // 4 × 4 patches, every point the same colour: 16 pieces, not 16 × 64. + let d = doc(4, 4, |_, _| Color::rgb8(40, 120, 200)); + let svg = export(&d, &ExportOptions::default()); + assert!(pieces(&svg) <= 16, "{} pieces", pieces(&svg)); + assert_looks_alike(&d, &svg, in_square, 2, 0.0); +} + +#[test] +fn a_patch_is_cut_no_finer_than_its_colour_changes() { + // Each patch changes by one 8-bit step from left to right: one piece is close enough. + let d = doc(2, 2, |_, c| Color::rgb8(100 + c as u8, 100 + c as u8, 100 + c as u8)); + let svg = export(&d, &ExportOptions::default()); + assert!(pieces(&svg) <= 4, "{} pieces", pieces(&svg)); + assert_looks_alike(&d, &svg, in_square, 2, 0.0); +} + +#[test] +fn a_strong_gradient_is_still_cut_finely() { + // Black to white across one patch: as many pieces as before, so the steps stay small. + let d = doc(1, 1, |_, c| if c == 0 { Color::BLACK } else { Color::WHITE }); + let svg = export(&d, &ExportOptions::default()); + assert!(pieces(&svg) >= 64, "{} pieces", pieces(&svg)); + // Flat pieces step where the canvas shades smoothly: within half a piece's step (255 / 16). + assert_looks_alike(&d, &svg, in_square, 17, 0.0); +} + +#[test] +fn a_curved_patch_of_one_colour_keeps_its_shape() { + // A mesh over a circle: its patches' edges are curves, which one piece must still follow. + let mut m = + GradientMesh::for_path(&shapes::ellipse(Rect::new(20.0, 20.0, 180.0, 180.0)), 2, 2, Color::BLACK, MeshAppearance::Flat, 0.0).expect("a mesh"); + for p in &mut m.points { + p.color = Color::rgb8(200, 40, 60); + } + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::new(id, NodeKind::Mesh(m))).unwrap(); + let svg = export(&d, &ExportOptions::default()); + assert!(pieces(&svg) <= 4, "{} pieces", pieces(&svg)); + // Two pixels in from the circle: the pieces' edges are straight between points 1/8 of a + // patch edge apart, as the quads' were. + assert_looks_alike(&d, &svg, |x, y| (x - 100.0).hypot(y - 100.0) < 78.0, 2, 0.0); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index dc23c77b7e..9c866d4223 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (PDF import: knockout and isolated groups keep their flags after a group drawn straight onto its parent) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (SVG export and Expand: a gradient mesh patch is cut no finer than its colour changes) · **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 @@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | SVG export (and Object › Expand, EPS, DXF, EMF and the other outputs that cut a gradient mesh into flat pieces): each patch was cut into 8 × 8 quads whatever its colours, so a patch of one colour became 64 identical pieces. Neighbouring quads are now joined into square blocks wherever the colour changes by no more than about one 8-bit step across the block, so a flat patch is one piece and a patch that shades strongly keeps all 64; a block's outline runs through its quads' corners, and a patch that folds over itself is not joined. A synthetic page of 400 meshes (6 × 6 patches, half flat) exports 468,000 pieces instead of 921,600 (35 MB instead of 66 MB), and resvg draws the two files identically. Tests `mesh_export` (crate `vectorcraft-svg`), `tests::live::mesh_expand_to_flat_pieces`; scores unchanged | | 2026-10-31 | minor | PDF import (and a `.ai`'s PDF part): a transparency group's knockout and isolate flags (`/K`, `/I`; ISO 32000-1, 11.4.6.3) come from the interpreter as it pushes the group. They were read from the file in order and matched to the groups pushed, but a group that adds nothing (full opacity, Normal, no mask, neither flag) is drawn straight onto its parent without being pushed, so every group after one lost its own flags and took another's; and a page drawing form groups more than 10,000 times failed to import (the list of their flags was capped), which it no longer does. A blend's steps written as a knockout group of falling opacity drew as solid dark shapes: a user's `.ai` with 143 such bubbles now draws them as light rings, as poppler does (its bubble area 4.9% → 1.2% of pixels off from poppler's render); 16 other users' PDFs with transparency groups (44 pages) draw the same. Test `tests::import_fidelity::knockout_groups_after_a_group_drawn_straight_keep_their_flag`; scores unchanged | | 2026-10-30 | minor | `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout; blends are written with knockout on) in the state block after its record, as a group takes the one after its end; it was left for the next object, so a blend's steps of falling opacity drew over each other instead of knocking each other out, and every such blend came in too dark (a user's file has 143, its bubbles came in as dark rings). Both a live blend and drawn steps kept as a group take it. Test `import::ai::tests::a_blends_transparency_follows_its_record`; checked locally against that file: it comes 1.3% → 0.6% nearer its page, though a mesh seen through a mesh-shaped opacity mask still keeps it just over the limit. When a blend is the layer's last object and the layer's own state block (`7 () XW`, then `LB`) follows, the state is the layer's and the blend keeps its own, as for meshes (#4748); scores unchanged | | 2026-10-31 | minor | PDF (and `.ai`) with Illustrator editing data saved with only some of its artboards: its pages are those artboards, while the editing data keeps them all, and the layers were compared with the first page on the first artboard, so a page of the second artboard came in as the PDF part, without its layers. With fewer pages than artboards, the first page is now taken to be the artboard of its size whose art draws most like it (up to 16 weighed), and the pages' type goes to that artboard and the ones after it. All artboards come in, as before. Test `import::tests_native::a_pdf_of_its_second_artboard_is_compared_on_that_artboard`; scores unchanged | From 64ffab00a99255ef1b116685f184c53820733f3c Mon Sep 17 00:00:00 2001 From: marshiyar <57055687+marshiyar@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:28:00 -0700 Subject: [PATCH 50/98] Swatch libraries: a library titled with a Windows device name is saved under another name (#3602) library_file_name kept CON, NUL, COM1 and the like, which Windows treats as devices, not files. It now adds a - after such a name (CON-.json). Part of #3602 --- crates/engine/src/cmd/swatchlib.rs | 11 +++++++++-- crates/engine/src/file_access.rs | 2 +- crates/engine/src/tests/swatchlib.rs | 16 ++++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index b7a0e2f299..d3652aa4e1 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -331,11 +331,18 @@ fn library_tab_name(file: &str, lib_name: &str) -> String { } /// The file name of library `name` with extension `ext`: characters file systems reject become -/// `-`, and dots and spaces at either end are dropped. A name with nothing left is `Library`. +/// `-`, and dots and spaces at either end are dropped. A name with nothing left is `Library`, and +/// one that is a Windows device name (`CON`, `NUL`, `COM1`…) gets a `-` after it. pub fn library_file_name(name: &str, ext: &str) -> String { let base: String = name.chars().map(|c| if "/\\:*?\"<>|".contains(c) || c.is_control() { '-' } else { c }).collect(); let base = base.trim_matches(['.', ' ']); - format!("{}.{ext}", if base.is_empty() { "Library" } else { base }) + if base.is_empty() { + format!("Library.{ext}") + } else if crate::file_access::is_device_name(base) { + format!("{base}-.{ext}") + } else { + format!("{base}.{ext}") + } } /// The library files with extensions `exts` in folder `dir`, sorted by name. diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 9930065132..56db72a222 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -274,7 +274,7 @@ fn same_component(a: Component, b: Component) -> bool { } /// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file. -fn is_device_name(component: &str) -> bool { +pub(crate) fn is_device_name(component: &str) -> bool { let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase(); let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")); matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT") diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index c20968db54..d6c9f84520 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -637,3 +637,19 @@ fn gradient_libraries_are_listed_and_gradient_swatches_rename_and_take_new_gradi run(&mut s, "swatch.edit", json!({"name": "Red", "paint": {"color": "#00ff00"}})); assert_eq!(doc(&s).swatch("Red").unwrap().paint.color().map(|c| c.to_hex()), Some("#00ff00".into())); } + +/// #3602: a library titled with a Windows device name is saved under a name that is not one. +#[test] +fn a_library_file_name_is_never_a_windows_device_name() { + use crate::cmd::swatchlib::library_file_name; + for (title, ext) in [("CON", "json"), ("nul", "json"), ("Com1", "acb"), ("LPT9", "ase"), ("aux.", "json")] { + let file = library_file_name(title, ext); + assert!(!crate::file_access::is_device_name(&file), "{title}: {file}"); + assert!( + file.ends_with(&format!(".{ext}")) && file.to_ascii_lowercase().contains(&title.trim_end_matches('.').to_ascii_lowercase()), + "{title}: {file}" + ); + } + assert_eq!(library_file_name("Console", "json"), "Console.json"); + assert_eq!(library_file_name("COM10", "json"), "COM10.json"); +} From ae7645572345f8b7444f21434ecf26dc43a029cb Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 30 Oct 2026 20:18:54 -0700 Subject: [PATCH 51/98] Perspective Selection: a move of loose art keeps its plane when 4 clears the active plane The move drag remembers the plane it places loose art on. Pressing 4 during the drag, or dragging on after it, previews and commits the move on that plane instead of dropping it. A 1-3 key pressed before the drag threshold also sets the plane the move uses. --- crates/engine/src/tests/persp_select.rs | 33 +++++++++++++++++++ crates/tools/src/distort/perspective.rs | 25 ++++++++++++++ crates/tools/src/distort/perspective/tools.rs | 30 ++++++++++------- 3 files changed, 77 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/tests/persp_select.rs b/crates/engine/src/tests/persp_select.rs index c590aee5b7..4ca4b46faf 100644 --- a/crates/engine/src/tests/persp_select.rs +++ b/crates/engine/src/tests/persp_select.rs @@ -281,6 +281,39 @@ fn shift_drag_keeps_its_displacement_after_a_plane_key() { } } +#[test] +fn loose_art_move_survives_clearing_the_active_plane() { + for (drag_after_key, switch_before_drag) in [(false, false), (true, false), (false, true)] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600, "created": null})).unwrap(); + s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); + s.execute("perspective.grid.snap", &json!({"on": false})).unwrap(); + s.execute("perspective.plane.set", &json!({"plane": "right"})).unwrap(); + let result = s.execute("shape.rectangle", &json!({"x": 450, "y": 380, "width": 60, "height": 60})).unwrap(); + let id = NodeId(result["id"].as_u64().unwrap()); + assert_eq!(attached(&s, id), None); + let before = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); + let (from, to) = (before.center(), before.center() + Vec2::new(30.0, 15.0)); + let v = ViewInfo::default(); + s.select_tool("perspectiveSelection", v).unwrap(); + let undo_before = undo_len(&s); + s.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y), v).unwrap(); + if switch_before_drag { + s.tool_key(ToolKey::Digit(2), Mods::default(), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Drag, to.x, to.y), v).unwrap(); + s.tool_key(ToolKey::Digit(4), Mods::default(), v).unwrap(); + assert_eq!(grid(&s).plane, Plane::None); + if drag_after_key { + s.pointer(&PointerEvent::new(PointerKind::Drag, to.x + 10.0, to.y), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Up, to.x, to.y), v).unwrap(); + assert_eq!(attached(&s, id), Some((if switch_before_drag { Plane::Ground } else { Plane::Right }, 0.0))); + assert_ne!(s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(), before); + assert_eq!(undo_len(&s), undo_before + 1); + } +} + #[test] fn cmd_alt_shift_drag_copies_on_a_plane_axis() { let (mut s, id) = persp_session(); diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index d535b5cd2a..7f3f374055 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -1286,6 +1286,31 @@ mod tests { assert_eq!(params["constrain"], json!(true)); } + #[test] + fn loose_move_preview_keeps_its_plane_when_the_active_plane_is_cleared() { + let (mut d, id) = doc_with_rect(); + let mut g = grid(2); + g.plane = Plane::Right; + g.store(&mut d); + let mut selection = Selection::default(); + selection.add(id); + let p = paint(); + let from = d.node(id).unwrap().geometric_bounds().unwrap().center(); + let to = from + Vec2::new(30.0, 15.0); + let mut tool = PerspectiveSelectionTool::default(); + let c = cx(&d, &selection, &p); + tool.pointer(&c, &PointerEvent::new(PointerKind::Down, from.x, from.y)); + let actions = tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x, to.y)); + assert!(matches!(actions.last(), Some(Action::Preview(_, params)) if params["plane"] == "right")); + g.plane = Plane::None; + g.store(&mut d); + let c = cx(&d, &selection, &p); + let actions = tool.key(&c, ToolKey::Digit(4), Mods::default()); + assert!(matches!(actions.last(), Some(Action::Preview(_, params)) if params["plane"] == "right"), "{actions:?}"); + let actions = tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x + 10.0, to.y)); + assert!(matches!(actions.last(), Some(Action::Preview(_, params)) if params["plane"] == "right"), "{actions:?}"); + } + #[test] fn selection_move_recomputes_shift_copy_and_perpendicular() { let (mut d, id) = doc_with_rect(); diff --git a/crates/tools/src/distort/perspective/tools.rs b/crates/tools/src/distort/perspective/tools.rs index e791f4ef33..915e4a8835 100644 --- a/crates/tools/src/distort/perspective/tools.rs +++ b/crates/tools/src/distort/perspective/tools.rs @@ -120,7 +120,7 @@ enum SelDrag { Idle, /// Pressed on an object: moves `ids` once the pointer leaves the drag threshold. `perp`: along /// the plane's normal (5 pressed while dragging); `copy` and `shift`: modifiers at the last event. - Move { ids: Vec<NodeId>, from: Point, last: Point, began: bool, perp: bool, copy: bool, shift: bool }, + Move { ids: Vec<NodeId>, from: Point, last: Point, plane: Plane, began: bool, perp: bool, copy: bool, shift: bool }, /// Dragging `handle` of the perspective bounding box `rect` (plane space, on `plane` at `depth`). Scale { ids: Vec<NodeId>, plane: Plane, depth: f64, rect: Rect, handle: BoxHandle }, /// Dragging a plane widget. @@ -164,9 +164,11 @@ pub(super) fn resize_cursor(d: Vec2) -> Cursor { impl PerspectiveSelectionTool { /// The `perspective.move` preview for the move drag (and its measurement label). fn move_preview(&mut self, cx: &ToolContext) -> Option<Action> { - let SelDrag::Move { ids, from, last, perp, copy, shift, .. } = &self.drag else { return None }; + let SelDrag::Move { ids, from, last, plane, perp, copy, shift, .. } = &self.drag else { return None }; let g = PerspectiveGrid::current(cx.doc); - let at = ids.first().and_then(|id| g.attachment_of(cx.doc, *id)).or((g.plane != Plane::None).then_some((g.plane, g.offset(g.plane)))); + let move_plane = if g.plane == Plane::None { *plane } else { g.plane }; + let at = + ids.first().and_then(|id| g.attachment_of(cx.doc, *id)).or((move_plane != Plane::None).then_some((move_plane, g.offset(move_plane)))); let constrained = if *shift && !*perp { at.and_then(|(plane, depth)| { let delta = g.plane_delta(plane, depth, *from, *last)?; @@ -179,8 +181,8 @@ impl PerspectiveSelectionTool { if *shift && !*perp { v["constrain"] = json!(true); } - if g.plane != Plane::None { - v["plane"] = json!(g.plane.id()); + if move_plane != Plane::None { + v["plane"] = json!(move_plane.id()); } self.measure = at.and_then(|(pl, depth)| { let text = if *perp { @@ -230,24 +232,26 @@ impl Tool for PerspectiveSelectionTool { vec![top] }; // Selecting an object in perspective makes its plane the active one. + let mut plane = g.plane; if let Some((pl, _)) = g.attachment_of(cx.doc, top) && pl != g.plane { acts.push(Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))); + plane = pl; } - self.drag = SelDrag::Move { ids, from: p, last: p, began: false, perp: false, copy: m.alt, shift: m.shift }; + self.drag = SelDrag::Move { ids, from: p, last: p, plane, began: false, perp: false, copy: m.alt, shift: m.shift }; acts } - (PointerKind::Drag, SelDrag::Move { ids, from, began, perp, .. }) => { + (PointerKind::Drag, SelDrag::Move { ids, from, plane, began, perp, .. }) => { if !began && p.distance(from) < cx.tol(3.0) { - self.drag = SelDrag::Move { ids, from, last: p, began, perp, copy: m.alt, shift: m.shift }; + self.drag = SelDrag::Move { ids, from, last: p, plane, began, perp, copy: m.alt, shift: m.shift }; return vec![]; } let mut out = vec![]; if !began { out.push(Action::Begin(if m.alt { "Copy in Perspective" } else { "Move in Perspective" }.into())); } - self.drag = SelDrag::Move { ids, from, last: p, began: true, perp, copy: m.alt, shift: m.shift }; + self.drag = SelDrag::Move { ids, from, last: p, plane, began: true, perp, copy: m.alt, shift: m.shift }; out.extend(self.move_preview(cx)); out } @@ -307,10 +311,14 @@ impl Tool for PerspectiveSelectionTool { return if *began { self.move_preview(cx).into_iter().collect() } else { vec![] }; } if matches!(key, ToolKey::Digit(1..=4)) - && let SelDrag::Move { began: true, shift, .. } = &mut self.drag + && let SelDrag::Move { began, plane, shift, .. } = &mut self.drag { *shift = mods.shift; - return self.move_preview(cx).into_iter().collect(); + let active = PerspectiveGrid::current(cx.doc).plane; + if active != Plane::None { + *plane = active; + } + return if *began { self.move_preview(cx).into_iter().collect() } else { vec![] }; } let (dx, dy) = match key { ToolKey::Left => (-1, 0), From 8363c815ecb7222f1a04b664d414ad0a3f3a6265 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 31 Oct 2026 00:05:46 -0700 Subject: [PATCH 52/98] Perspective: a transform's overflow fallback keeps each entry's powers of two apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the conjugate a ∘ self ∘ a⁻¹ overflows, conjugated now forms every entry of the product as a sum of terms whose factors are split into a mantissa and a power of two, and scales the whole product by one power of two from its w row at the end. Scaling each partial product to a largest entry of 1 could round an entry to 0 that the next factor would restore, so a projection with entries far apart in size, such as a scale by (1e160, 1e-160) seen through the same scale, was removed and the art drew flat. --- crates/geom/src/projective.rs | 89 +++++++++++++++++++++++++---------- 1 file changed, 63 insertions(+), 26 deletions(-) diff --git a/crates/geom/src/projective.rs b/crates/geom/src/projective.rs index 4b17aac60d..ddf836a23f 100644 --- a/crates/geom/src/projective.rs +++ b/crates/geom/src/projective.rs @@ -100,11 +100,11 @@ impl Homography { } /// `self` seen through `a`: the map `a ∘ self ∘ a⁻¹` (what `self` becomes when everything it - /// maps from and to is moved by `a`). When the product overflows, it is formed one factor at a - /// time, each partial product scaled to a largest entry of 1, and returned divided by the - /// largest magnitude in its `w` row (the same map, with a `w` row whose largest entry is 1). - /// `None` when `a` isn't invertible, or when the product overflows and the result at that - /// scale has an entry that is not finite or is not invertible. + /// maps from and to is moved by `a`). When the product overflows, every entry is formed with + /// its powers of two kept apart, and the product is returned divided by the largest magnitude + /// in its `w` row (the same map, with a `w` row whose largest entry is 1). `None` when `a` + /// isn't invertible, or when the product overflows and the result at that scale has an entry + /// that is not finite or is not invertible. pub fn conjugated(&self, a: Affine) -> Option<Self> { if a.determinant().abs() < 1e-15 { return None; @@ -114,12 +114,28 @@ impl Homography { if c.is_finite() { return Some(c); } - let c = to.then_after_normalized(&self.normalized()?)?.then_after_normalized(&from)?; - let k = c.0[2].iter().fold(0.0_f64, |k, v| k.max(v.abs())); + // Each entry of the product is a sum of nine terms to[r][i]·self[i][j]·from[j][k]. Every + // factor is split into a mantissa and a power of two, so each term is a mantissa product + // and an exponent sum, and the whole product is scaled by one power of two at the end. + let split = |h: &Self| h.0.map(|row| row.map(frexp)); + let (t, s, f) = (split(&to), split(self), split(&from)); + let term = |r: usize, i: usize, j: usize, k: usize| { + let (a, b, c) = (t[r][i], s[i][j], f[j][k]); + (a.0 * b.0 * c.0, a.1 + b.1 + c.1) + }; + let ijk = (0..27).map(|n| (n / 9, n / 3 % 3, n % 3)); + let e = ijk.map(|(i, j, k)| term(2, i, j, k)).filter(|(m, _)| *m != 0.0).map(|(_, e)| e).max()?; + let mut c = [[0.0; 3]; 3]; + for (r, row) in c.iter_mut().enumerate() { + for (k, v) in row.iter_mut().enumerate() { + *v = (0..9).map(|n| term(r, n / 3, n % 3, k)).map(|(m, x)| ldexp(m, x - e)).sum(); + } + } + let k = c[2].iter().fold(0.0_f64, |k, v| k.max(v.abs())); if k <= 0.0 { return None; } - let c = Self(c.0.map(|row| row.map(|v| v / k))); + let c = Self(c.map(|row| row.map(|v| v / k))); (c.is_finite() && c.det().abs() > 1e-15).then_some(c) } @@ -127,24 +143,6 @@ impl Homography { self.0.iter().flatten().all(|v| v.is_finite()) } - /// `self` divided by its largest entry magnitude, a positive scale that maps every point to - /// the same place; `None` when an entry is not finite or every entry is 0. - fn normalized(&self) -> Option<Self> { - let k = self.0.iter().flatten().fold(0.0_f64, |k, v| k.max(v.abs())); - (self.is_finite() && k > 0.0).then(|| Self(self.0.map(|row| row.map(|v| v / k)))) - } - - /// `self ∘ other` divided by its largest entry magnitude; when that product overflows, it is - /// formed from both factors divided by their largest entry magnitudes. `None` when the result - /// has an entry that is not finite or every entry is 0. - fn then_after_normalized(&self, other: &Self) -> Option<Self> { - let c = self.then_after(other); - if c.is_finite() { - return c.normalized(); - } - self.normalized()?.then_after(&other.normalized()?).normalized() - } - /// The affine map that matches `self` to first order at `p` (its derivative there), `None` /// beyond the horizon or when the derivative is not finite. pub fn affine_at(&self, p: Point) -> Option<Affine> { @@ -171,6 +169,36 @@ impl Homography { } } +/// `v` as `m·2^e` with `0.5 ≤ |m| < 1`; 0, infinities and NaN come back as `(v, 0)`. +fn frexp(v: f64) -> (f64, i32) { + if v == 0.0 || !v.is_finite() { + return (v, 0); + } + // A subnormal is scaled into the normal range first. + let (v, bias) = if v.abs() < f64::MIN_POSITIVE { (v * pow2(64), -64) } else { (v, 0) }; + let bits = v.to_bits(); + let e = ((bits >> 52) & 0x7ff) as i32 - 1022; + (f64::from_bits((bits & !(0x7ff << 52)) | (1022 << 52)), e + bias) +} + +/// `m·2^e`, multiplied in steps of powers of two that are each within the normal range. +fn ldexp(mut m: f64, mut e: i32) -> f64 { + while e > 1023 && m.is_finite() { + m *= pow2(1023); + e -= 1023; + } + while e < -1022 && m != 0.0 { + m *= pow2(-1022); + e += 1022; + } + m * pow2(e.clamp(-1022, 1023)) +} + +/// `2^e` for `-1022 ≤ e ≤ 1023`. +fn pow2(e: i32) -> f64 { + f64::from_bits(((e + 1023) as u64) << 52) +} + #[cfg(test)] mod tests { use super::*; @@ -288,6 +316,15 @@ mod tests { assert!((v - w).abs() <= 1e-9 * w.abs().max(1.0), "{c:?}"); } assert_eq!(Homography::from_array(c.to_array()), Some(c)); + // A scale by (1e160, 1e-160) seen through the same scale is itself, though the product's + // first entry is 1e320 and a scale to a largest entry of 1 takes its second to 1e-480. + let thin = Homography([[1e160, 0.0, 0.0], [0.0, 1e-160, 0.0], [0.0, 0.0, 1.0]]); + assert!(Homography::from_array(thin.to_array()).is_some()); + let c = thin.conjugated(Affine::scale_non_uniform(1e160, 1e-160)).unwrap(); + for (v, w) in c.to_array().iter().zip(thin.to_array()) { + assert!((v - w).abs() <= 1e-9 * w.abs(), "{c:?}"); + } + assert_eq!(Homography::from_array(c.to_array()), Some(c)); // A move by 1e200 seen through a scale by 1e150 sends every point to about (1e350, 0), // which no invertible map of finite numbers draws. let far = Homography([[1.0, 0.0, 1e200], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); From f28de75a225f4f8a2e4e9d3069534f3440951239 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 30 Oct 2026 17:47:29 -0700 Subject: [PATCH 53/98] Shortcuts: an exact Shift binding for the typed character wins over a plain binding at Shift+Comma or Shift+Period --- crates/ui-egui/src/shortcuts.rs | 16 ++++++++-------- crates/ui-egui/src/shortcuts/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 8425456b99..cb66d71c1d 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -731,8 +731,8 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, accept: impl FnMut(&H let mirror = crate::shortcut_editor::current(); let dispatcher = &mut app.dispatcher; ctx.input_mut(|i| { - // Shift+Comma and Shift+Period follow their key positions when no chord for the - // logical press is bound. An explicit shifted logical binding takes precedence. + // An explicit shifted logical binding takes precedence. Otherwise, Shift+Comma and + // Shift+Period follow their key positions when no chord for the press is bound. for e in &mut i.events { let input = craft_keymap::KeyInput::from_egui(e, crate::keymap::platform()); if let egui::Event::Key { key, physical_key: Some(physical), modifiers, pressed: true, .. } = e @@ -749,12 +749,12 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, accept: impl FnMut(&H let mods = craft_keymap::Mods::from_egui(modifiers, crate::keymap::platform()); let logical_chord = craft_keymap::Chord::new(mods, logical); let position_chord = craft_keymap::Chord::new(mods, position); - if !dispatcher.layout().candidates(&input).iter().any(|chord| crate::keymap::lookup(&mirror.keymap, chord).is_some()) { - if crate::keymap::lookup(&mirror.keymap, &logical_chord).is_some() { - *physical = *key; - } else if crate::keymap::lookup(&mirror.keymap, &position_chord).is_some() { - *key = *physical; - } + if crate::keymap::lookup(&mirror.keymap, &logical_chord).is_some() { + *physical = *key; + } else if crate::keymap::lookup(&mirror.keymap, &position_chord).is_some() + && !dispatcher.layout().candidates(&input).iter().any(|chord| crate::keymap::lookup(&mirror.keymap, chord).is_some()) + { + *key = *physical; } } } diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index ceb68108c7..c5005fba9d 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -1251,6 +1251,27 @@ fn resolved_logical_binding_wins_over_position_alias() { } } +#[test] +fn exact_shifted_logical_binding_wins_over_plain_text_binding() { + let mut app = remembered_paint_app(); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "view.rulers", ";"); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "view.grid", "Shift+;"); + let rulers_before = app.ui.view.rulers; + let grid_before = app.ui.view.grid; + let journal_before = app.session.journal.len(); + frame( + &mut app, + vec![ + egui::Event::ModifiersChanged(Modifiers::SHIFT), + egui::Event::Key { key: Key::Semicolon, physical_key: Some(Key::Comma), pressed: true, repeat: false, modifiers: Modifiers::SHIFT }, + egui::Event::Text(";".into()), + ], + ); + assert_eq!(app.ui.view.grid, !grid_before, "Shift+; runs Show Grid"); + assert_eq!(app.ui.view.rulers, rulers_before, "plain ; does not run Show Rulers"); + assert!(app.session.journal.since(journal_before).iter().all(|(command, _)| !command.starts_with("paint.")), "the press runs no paint command"); +} + #[test] fn user_shifted_punctuation_binding_runs_once() { let mut app = remembered_paint_app(); From b13b3260cd86e4ecf243b1be15c1fd8cc803967c Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 31 Oct 2026 18:48:07 +0700 Subject: [PATCH 54/98] Keyboard Shortcuts: a shortcut on the Turkish Q quotation-mark key shows " and runs Squashed from #4851 onto main. --- crates/ui-egui/src/chrome.rs | 2 +- crates/ui-egui/src/chrome/hints.rs | 4 +- crates/ui-egui/src/edit_toolbar.rs | 2 +- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/menus/bar.rs | 4 +- crates/ui-egui/src/menus/entries.rs | 6 ++ crates/ui-egui/src/palette.rs | 2 +- crates/ui-egui/src/shortcut_editor.rs | 139 +++++++++++++++++++++++--- crates/ui-egui/src/shortcuts.rs | 28 ++++-- crates/ui-egui/src/shortcuts/tests.rs | 49 +++++++++ crates/ui-egui/src/toolbar.rs | 7 +- docs/target-app-parity.md | 1 + 12 files changed, 213 insertions(+), 33 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index dea0bd3a49..b9c5f11fc9 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -101,7 +101,7 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { // Search → command palette (its shortcut opens it too). if search { let tip = match menus::shortcut_of("help.commandPalette") { - Some(sc) => format!("{} ({})", tl!("Search commands and tools"), menus::pretty_shortcut(sc)), + Some(sc) => format!("{} ({})", tl!("Search commands and tools"), menus::shown_shortcut(sc)), None => tl!("Search commands and tools").to_string(), }; if widgets::icon_button(ui, "search", &tip, app.ui.palette_open, icon).clicked() { diff --git a/crates/ui-egui/src/chrome/hints.rs b/crates/ui-egui/src/chrome/hints.rs index 7fc2e749b7..7d85a07be1 100644 --- a/crates/ui-egui/src/chrome/hints.rs +++ b/crates/ui-egui/src/chrome/hints.rs @@ -214,11 +214,11 @@ pub(super) fn hint_segments(tool: &str) -> Vec<(Cow<'static, str>, bool)> { } match text.rsplit_once('+') { Some((_, key)) if !key.is_empty() => { - let pretty = menus::pretty_shortcut(text); + let pretty = menus::shown_shortcut(text); let shown = pretty.strip_suffix(key).map_or(pretty.clone(), |m| format!("{m}{}", tl!(key))); (Cow::Owned(shown), bold) } - _ => (Cow::Owned(menus::pretty_shortcut(tl!(text))), bold), + _ => (Cow::Owned(menus::shown_shortcut(tl!(text))), bold), } }) .collect() diff --git a/crates/ui-egui/src/edit_toolbar.rs b/crates/ui-egui/src/edit_toolbar.rs index 8ec296aba1..34f5c77828 100644 --- a/crates/ui-egui/src/edit_toolbar.rs +++ b/crates/ui-egui/src/edit_toolbar.rs @@ -602,7 +602,7 @@ fn row(ui: &mut Ui, t: &Tokens, tool: &'static ToolInfo, on: bool, dimmed: bool) let icon = Rect::from_center_size(rect.left_center() + vec2(8.0 + ICON / 2.0, 0.0), vec2(ICON, ICON)); icons::paint(ui, icons::tool_icon(tool.icon), icon, if dimmed || on { color } else { t.icon }); let painter = ui.painter().with_clip_rect(rect); - let shortcut = crate::shortcut_editor::tool_shortcut(tool.id); + let shortcut = crate::shortcut_editor::tool_shortcut(tool.id).map(crate::shortcut_editor::as_typed); let keys_w = shortcut.as_ref().map_or(0.0, |k| { let keys = if on { t.text_strong diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2c8c051f66..a419204162 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -62,7 +62,7 @@ pub(crate) use bar::{home_key, home_showing}; #[cfg(test)] use bar::{menu_body, pointer_reaches_title}; pub use context::{context_items, context_menu_body, ruler_menu_body, ruler_unit_items}; -pub use entries::{MenuEntry, Row, context_entries, entry, menu_entries, pretty_shortcut}; +pub use entries::{MenuEntry, Row, context_entries, entry, menu_entries, pretty_shortcut, shown_shortcut}; pub(crate) use invoke::graph_dialog; pub use invoke::{click_target, invoke, invoke_from_system_menu}; #[cfg(test)] diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs index 1fc2f7855c..a0e4e0d6ac 100644 --- a/crates/ui-egui/src/menus/bar.rs +++ b/crates/ui-egui/src/menus/bar.rs @@ -1,4 +1,4 @@ -use super::entries::{Row, entry, pretty_shortcut}; +use super::entries::{Row, entry, shown_shortcut}; use super::invoke::{field_event, focused_text_field, invoke, to_field, with_held_keys}; use super::tree::menu_tree_named; use super::{Entry, Item, Tokens, Value, VectorcraftApp, widgets}; @@ -124,7 +124,7 @@ pub(super) fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Ite /// One menu item: its label (with a check mark, or the room for one, under `checks`), its shortcut, /// and Type › Font's samples. fn render_row(app: &VectorcraftApp, ui: &mut egui::Ui, row: &Row, checks: bool, clicked: &mut Option<(String, Value)>) { - let sc = row.shortcut.map(pretty_shortcut).unwrap_or_default(); + let sc = row.shortcut.map(shown_shortcut).unwrap_or_default(); let Some((id, p)) = row.command else { ui.add_enabled_ui(false, |ui| { ui.add(egui::Button::new(row.label.as_ref()).shortcut_text(sc)); diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs index fa78fef80e..e9cfc538a1 100644 --- a/crates/ui-egui/src/menus/entries.rs +++ b/crates/ui-egui/src/menus/entries.rs @@ -88,6 +88,12 @@ pub fn pretty_shortcut(s: &str) -> String { } } +/// [`pretty_shortcut`] with the backtick key shown as the character it types on the layout in use +/// (`"` on Turkish Q), as Edit › Keyboard Shortcuts shows it. +pub fn shown_shortcut(s: &str) -> String { + pretty_shortcut(&crate::shortcut_editor::as_typed(s)) +} + #[derive(Serialize)] pub struct MenuEntry { pub path: Vec<String>, diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index f7728f4c33..ec205e4f54 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -115,7 +115,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { for (i, e) in matches.iter().enumerate() { let resp = ui.add( egui::Button::new(e.shown.as_str()) - .shortcut_text(menus::pretty_shortcut(&e.shortcut)) + .shortcut_text(menus::shown_shortcut(&e.shortcut)) .min_size(egui::vec2(500.0, 24.0)) .selected(i == sel), ); diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 4d6164f111..c048421c3d 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -271,6 +271,57 @@ pub fn chord_from_event(key: Key, m: Modifiers) -> Option<String> { crate::shortcuts::parse(&s).map(|_| s) } +ui_mirror!(fn backtick_store() -> Option<String>); + +/// Notes what the backtick key (left of 1) typed in `events`: the one-character text whose key, a +/// press of it without modifiers, comes right before it (egui-winit) or, when no press comes +/// before, right after it (eframe's web build with no text field focused). egui reports that key +/// as [`Key::Backtick`] on layouts where it types a character egui has no key for, such as `"` on +/// Turkish Q. +pub(crate) fn note_backtick_text(events: &[egui::Event]) { + let is_press = |e: Option<&egui::Event>| matches!(e, Some(egui::Event::Key { pressed: true, .. })); + let is_backtick = + |e: Option<&egui::Event>| matches!(e, Some(egui::Event::Key { key: Key::Backtick, pressed: true, modifiers, .. }) if modifiers.is_none()); + let typed = events.iter().enumerate().rev().find_map(|(j, e)| match e { + egui::Event::Text(t) if t.chars().count() == 1 && !t.chars().any(char::is_whitespace) => { + let before = j.checked_sub(1).and_then(|j| events.get(j)); + (is_backtick(before) || (!is_press(before) && is_backtick(events.get(j + 1)))).then(|| t.clone()) + } + _ => None, + }); + if let Some(t) = typed + && let Ok(mut store) = backtick_store().write() + && store.as_deref() != Some(t.as_str()) + { + *store = Some(t); + } +} + +/// The character the backtick key types on the layout in use, once a press of it has been seen, +/// when it is neither `` ` `` nor `~`. +pub(crate) fn backtick_text() -> Option<String> { + backtick_store().read().ok().and_then(|t| t.clone()).filter(|t| t != "`" && t != "~") +} + +/// `text` as the editor shows it: an unshifted backtick key shows the character it types on the +/// layout in use (`"` on Turkish Q), so it reads as the key the user pressed. +fn on_layout(text: String, typed: Option<&str>) -> String { + let Some(typed) = typed else { return text }; + match vc_chord(&text) { + Some(c) if c.key == craft_keymap::Key::Backtick && !c.mods.shift => match text.strip_suffix('~') { + Some(mods) => format!("{mods}{typed}"), + None => text, + }, + _ => text, + } +} + +/// `text` with the backtick key shown as the character it types on the layout in use +/// ([`backtick_text`]), as the editor shows it. +pub(crate) fn as_typed(text: &str) -> String { + on_layout(text.to_string(), backtick_text().as_deref()) +} + // ---------- entries & conflicts ---------- #[derive(Clone, Debug)] @@ -506,15 +557,17 @@ pub fn reset_one(overrides: &mut Overrides, key: &str) { overrides.remove(&crate::keymap::stored_key(key)); } -/// Text shown for all active chords in a dialog row. -fn dialog_shortcuts_with(km: &Keymap, key: &str) -> Option<String> { +/// Text shown for all active chords in a dialog row, with the backtick key shown as `typed` +/// ([`backtick_text`]). +fn dialog_shortcuts_with(km: &Keymap, key: &str, typed: Option<&str>) -> Option<String> { + let shown = |s: String| menus::pretty_shortcut(&on_layout(s, typed)); let bindings = km.bindings(&command_of(key)); if bindings.len() > 1 { - let primary = effective_with(km, key).map(|s| menus::pretty_shortcut(&s))?; - let additional = bindings.iter().skip(1).map(|b| menus::pretty_shortcut(&crate::keymap::binding_text(b))); + let primary = effective_with(km, key).map(shown)?; + let additional = bindings.iter().skip(1).map(|b| shown(crate::keymap::binding_text(b))); return Some(std::iter::once(primary).chain(additional).collect::<Vec<_>>().join(" · ")); } - effective_with(km, key).map(|s| menus::pretty_shortcut(&s)) + effective_with(km, key).map(shown) } /// Overrides of a preset (both presets are the registry defaults for now). @@ -698,6 +751,8 @@ pub fn is_recording(app: &VectorcraftApp) -> bool { struct DialogView { /// The dialog's `overrides` field this was built from. source: Value, + /// The [`backtick_text`] the texts show. + backtick: Option<String>, keymap: Keymap, texts: Vec<Option<String>>, conflicts: usize, @@ -706,17 +761,19 @@ struct DialogView { impl DialogView { fn new(overrides: Overrides) -> DialogView { let keymap = keymap_with(&overrides); - let texts = entries().iter().map(|e| dialog_shortcuts_with(&keymap, &e.key)).collect(); + let backtick = backtick_text(); + let texts = 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()), keymap, texts, conflicts } + DialogView { source: stored::to_value(keymap.overrides()), backtick, keymap, texts, conflicts } } - /// The view of `d`'s working set, from the cache unless the set 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 or the backtick key's + /// character changed. The dialog's `overrides` field is left in its stored form. fn of(ctx: &egui::Context, d: &mut Dialog) -> Arc<DialogView> { let id = egui::Id::unique("dialog-shortcuts-view"); let source = d.fields.get("overrides").unwrap_or(&Value::Null); - if let Some(v) = ctx.data(|m| m.get_temp::<Arc<DialogView>>(id)).filter(|v| v.source == *source) { + let backtick = backtick_text(); + if let Some(v) = ctx.data(|m| m.get_temp::<Arc<DialogView>>(id)).filter(|v| v.source == *source && v.backtick == backtick) { return v; } let v = Arc::new(DialogView::new(dialog_overrides(d))); @@ -729,6 +786,7 @@ impl DialogView { pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.dialog.clone() else { return }; let t = Tokens::get(ctx); + ctx.input(|i| note_backtick_text(&i.events)); // The working set, as a keymap: copied only by an edit this frame. let view = DialogView::of(ctx, &mut d); let mut km = Cow::Borrowed(&view.keymap); @@ -823,7 +881,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { for (i, e) in 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), + 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) @@ -1010,7 +1068,10 @@ fn record(d: &mut Dialog, km: &mut Keymap, key: &str, chord: &str) { "__message".into(), json!(crate::i18n::fmt( tl!("⚠ {chord} was already used by {names} — it has been removed there."), - &[("chord", &menus::pretty_shortcut(chord)), ("names", &names.join(", "))] + &[ + ("chord", &menus::pretty_shortcut(&on_layout(chord.to_string(), backtick_text().as_deref()))), + ("names", &names.join(", ")) + ] )), ); d.fields.insert("__conflict".into(), json!(first)); @@ -1126,6 +1187,60 @@ mod tests { assert_eq!(crate::keymap::override_text(&dialog_overrides(&d), "tool:groupSelection").as_deref(), Some("Shift+A")); } + /// On Turkish Q the key left of 1 types `"`, which egui reports as the backtick key: the + /// recorded shortcut is that key, saved as `~` as before, and its row shows `"`. On a US + /// layout the row still shows `~`. + #[test] + fn the_backtick_key_shows_what_it_types() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + 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<egui::Event>| { + app.ui.dialog.as_mut().unwrap().fields.insert("__recording".into(), json!("edit.clear")); + 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(); + (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()))); + assert_eq!(record_with(vec![press(Key::Backtick), text("`")]), (Some("~".to_string()), Some("~".to_string()))); + // eframe's web build with no text field focused sends the text before its key. + assert_eq!(record_with(vec![text("\""), press(Key::Backtick)]), (Some("~".to_string()), Some("\"".to_string()))); + // A text right after another key's press is that key's. + note_backtick_text(&[press(Key::Num2), text("'"), press(Key::Backtick)]); + assert_eq!(backtick_text().as_deref(), Some("\"")); + note_backtick_text(&[text("`"), press(Key::Backtick)]); + assert_eq!(backtick_text(), None); + // Only the unshifted key types that character. + assert_eq!(on_layout("Shift+~".into(), Some("\"")), "Shift+~"); + assert_eq!(on_layout("Cmd+~".into(), Some("\"")), "Cmd+\""); + assert_eq!(on_layout("Cmd+K".into(), Some("\"")), "Cmd+K"); + } + + #[test] + fn menus_and_tooltips_show_the_backtick_key_as_it_types() { + let mut ui = UiState::default(); + crate::keymap::set_override(&mut ui.shortcut_overrides, "edit.clear", "Cmd+~"); + crate::keymap::set_override(&mut ui.shortcut_overrides, "tool:measure", "~"); + sync(&ui); + let measure = vectorcraft_tools::tool_info("measure").unwrap(); + let press = |key| egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: Modifiers::NONE }; + let clear = || menus::shown_shortcut(menus::shortcut_of("edit.clear").unwrap()); + note_backtick_text(&[press(Key::Backtick), egui::Event::Text("`".into())]); + assert_eq!(clear(), menus::pretty_shortcut("Cmd+~")); + assert!(crate::toolbar::tip(measure).ends_with("(~)")); + note_backtick_text(&[press(Key::Backtick), egui::Event::Text("\"".into())]); + assert_eq!(clear(), menus::pretty_shortcut("Cmd+\"")); + assert!(crate::toolbar::tip(measure).ends_with("(\")"), "{}", crate::toolbar::tip(measure)); + assert_eq!(menus::shown_shortcut("Shift+~"), menus::pretty_shortcut("Shift+~")); + sync(&UiState::default()); + } + #[test] fn saved_modifier_only_overrides_give_the_default_back() { let mut ui = UiState::default(); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index cb66d71c1d..e8e5d07fed 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -169,6 +169,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { if !crate::take_frame_once(&mut app.shortcuts_frames, frame) { return; } + ctx.input(|i| crate::shortcut_editor::note_backtick_text(&i.events)); if !(ctx.input(|input| input.focused) || app.forwarded_frame && ctx.viewport_id() == egui::ViewportId::ROOT) { crate::ime::controls_without_editor(app, ctx); return; @@ -357,25 +358,26 @@ fn remember_escape_pair(ctx: &egui::Context, key: Key, escape_pair: Option<bool> fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) { // Single-key tool shortcuts (no Cmd/Ctrl/Alt). With a layout without Latin letters (Persian, // Arabic, Russian, Greek…) the key's position stands in for what it types (#793). - let events: Vec<(String, Modifiers)> = ctx.input(|i| { + // A text's key is the press that typed it ([`typing_presses`]). + let events: Vec<(String, Modifiers, Option<Key>)> = ctx.input(|i| { let typed_by = typing_presses(&i.events); i.events .iter() .zip(typed_by) .filter_map(|(e, by)| match e { egui::Event::Text(t) => { - let physical = match by.and_then(|k| i.events.get(k)) { - Some(egui::Event::Key { physical_key, .. }) => *physical_key, - _ => None, + let (physical, key) = match by.and_then(|k| i.events.get(k)) { + Some(egui::Event::Key { key, physical_key, .. }) => (*physical_key, Some(*key)), + _ => (None, None), }; - Some((latin(t, physical), i.modifiers)) + Some((latin(t, physical), i.modifiers, key)) } _ => None, }) .collect() }); let keymap = crate::shortcut_editor::current(); - for (text, m) in events { + for (text, m, pressed) in events { if m.command || m.alt || m.ctrl { continue; } @@ -384,11 +386,15 @@ fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) { // A shifted punctuation character can also be written with its Shift (the Curvature tool's // Shift+~ arrives as `~`). let shifted = m.shift.then(|| format!("Shift+{text}")); - let hits: Vec<CommandRef> = [Some(key), Some(text), shifted] - .into_iter() - .flatten() - .filter_map(|t| crate::keymap::vc_chord(&t).and_then(|c| crate::keymap::lookup(&keymap.keymap, &c))) - .collect(); + let mut chords: Vec<craft_keymap::Chord> = + [Some(key), Some(text), shifted].into_iter().flatten().filter_map(|t| crate::keymap::vc_chord(&t)).collect(); + // A character no chord names (`"`, which the backtick key types on Turkish Q) is read as + // the key that typed it, as the Keyboard Shortcuts editor records that press. + if chords.is_empty() { + let typed_by = pressed.and_then(|k| crate::shortcut_editor::chord_from_event(k, Modifiers { shift: m.shift, ..Modifiers::NONE })); + chords.extend(typed_by.and_then(|t| crate::keymap::vc_chord(&t))); + } + let hits: Vec<CommandRef> = chords.iter().filter_map(|c| crate::keymap::lookup(&keymap.keymap, c)).collect(); // Single-key command shortcuts (X, Shift+X, D, /, Shift+D, F, Shift+F by default) before // tools. let Some(hit) = hits.iter().find(|c| !c.id.starts_with("tool:")).or(hits.first()) else { continue }; diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index c5005fba9d..38365f5ed1 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -1744,6 +1744,55 @@ fn a_repeated_keys_text_is_no_shortcut_press() { assert_eq!(only_text.len(), 1); } +/// A shortcut on the backtick key runs whatever character the key types: `"` on Turkish Q, +/// which egui reports as the backtick key, and `` ` `` on a US layout. A shortcut saved as `~` +/// is that key's. +#[test] +fn a_shortcut_on_the_backtick_key_runs_on_every_layout() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let recorded = crate::shortcut_editor::chord_from_event(Key::Backtick, Modifiers::NONE).unwrap(); + assert_eq!(recorded, "~"); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "edit.clear", "~"); + let count = |app: &VectorcraftApp| app.session.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len()); + let press = egui::Event::Key { key: Key::Backtick, physical_key: Some(Key::Backtick), pressed: true, repeat: false, modifiers: Modifiers::NONE }; + for typed in ["\"", "`"] { + 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(); + assert_eq!(count(&app), 1); + frame(&mut app, vec![press.clone(), egui::Event::Text(typed.into())]); + assert_eq!(count(&app), 0, "the key typing {typed} runs Edit › Clear"); + } + // A character no key names, typed by a key with no shortcut, runs nothing. + 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 num2 = egui::Event::Key { key: Key::Num2, physical_key: Some(Key::Num2), pressed: true, repeat: false, modifiers: Modifiers::NONE }; + frame(&mut app, vec![num2, egui::Event::Text("\"".into())]); + assert_eq!(count(&app), 1); +} + +/// eframe's web build with no text field focused sends the text before its key: the key after +/// the `"` it typed is read when no press comes right before it. +#[test] +fn a_shortcut_on_the_backtick_key_runs_when_its_text_comes_first() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "edit.clear", "~"); + let count = |app: &VectorcraftApp| app.session.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len()); + let press = egui::Event::Key { key: Key::Backtick, physical_key: Some(Key::Backtick), pressed: true, repeat: false, modifiers: Modifiers::NONE }; + let select_new = |app: &mut VectorcraftApp| { + 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(); + }; + select_new(&mut app); + frame(&mut app, vec![egui::Event::Text("\"".into()), press.clone()]); + assert_eq!(count(&app), 0, "the key after the text typed it"); + // An earlier press that is not next to the text does not stand in for its key. + select_new(&mut app); + frame(&mut app, vec![press, egui::Event::ModifiersChanged(Modifiers::NONE), egui::Event::Text("\"".into())]); + assert_eq!(count(&app), 1); +} + /// Shortcuts reach tools, panels (`window.panel {panel}`) and commands through the keymap, /// and a held Cmd+= keeps zooming in. #[test] diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 33a2eb10b8..32c2ad971a 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -68,7 +68,7 @@ pub const BASIC: &[(&str, &[&[&str]])] = &[ pub(crate) fn tip(t: &ToolInfo) -> String { match crate::shortcut_editor::tool_shortcut(t.id) { - Some(s) => format!("{} ({})", tl!(t.label), s), + Some(s) => format!("{} ({})", tl!(t.label), crate::shortcut_editor::as_typed(s)), None => tl!(t.label).to_string(), } } @@ -694,7 +694,10 @@ const ROW_END: f32 = 18.0; /// The name of `tool` in `lang` and its shortcut in brackets, as a flyout row draws them. fn row_text(ui: &Ui, lang: crate::i18n::Lang, tool: &ToolInfo, color: Color32) -> RowText { let lay = |s: String| ui.painter().layout_no_wrap(s, egui::FontId::proportional(13.0), color); - (lay(crate::i18n::tr(lang, tool.label).to_string()), crate::shortcut_editor::tool_shortcut(tool.id).map(|sc| lay(format!("({sc})")))) + ( + lay(crate::i18n::tr(lang, tool.label).to_string()), + crate::shortcut_editor::tool_shortcut(tool.id).map(|sc| lay(format!("({})", crate::shortcut_editor::as_typed(sc)))), + ) } /// The width of a flyout's rows: 250 pt, or what its longest name needs to stay clear of its diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2f6ee32b76..5d1ce531d2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -633,6 +633,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.*`). - 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. - 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). - On Windows a maximized window stops one pixel short of each monitor edge where an auto-hidden taskbar sits. - **Tools:** From a1a3a9910450addaefdaffe7b0de8c83ca274e80 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:00:19 +0430 Subject: [PATCH 55/98] Fix #3592: a gradient mesh from a file is budgeted at about a million quads A mesh read from a file can have any number of patches, and the view any zoom: a 150 x 150 mesh from a 3 MB file exported at 20x asked for about 23 million quads (2 GB), and the command's own cap of 50 rows and columns does not apply to a loaded one. One tessellation now makes at most 1,048,576 quads, the subdivision per patch giving way before that, and a mesh with more patches than that is malformed, like a point count that does not fit. --- crates/doc/src/live.rs | 23 +++++++++++++++++++++++ crates/doc/src/live/mesh.rs | 18 +++++++++++++++--- docs/gaps.md | 9 ++++++++- docs/target-app-parity.md | 3 ++- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 6a29b5b9ef..258035c10f 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -181,6 +181,29 @@ mod tests { assert!((p.x - 37.0).abs() < 1e-6 && (p.y - 81.0).abs() < 1e-6, "{p:?}"); } + /// #3592: a mesh read from a file can have any number of patches, and the view any zoom, so the + /// quads one tessellation makes are budgeted: the subdivision per patch gives way before memory + /// does, and each patch stays at least one quad. + #[test] + fn a_large_mesh_tessellates_within_the_quad_budget() { + let mesh = |n: u32| GradientMesh::from_surface(n, n, &|u, v| Point::new(u * 300.0, v * 300.0), &|u, v| Color::rgb(u as f32, v as f32, 0.5)); + // 3,600 patches at 32 x 32 would be 3.7 million quads. + let m = mesh(60); + let quads = m.quads(32); + assert!(quads.len() <= GradientMesh::MAX_QUADS, "{} quads", quads.len()); + assert!(quads.len() >= 3600 * 4, "still finer than a quad per patch: {}", quads.len()); + // A small mesh keeps the subdivision asked for. + assert_eq!(mesh(3).quads(8).len(), 9 * 64); + // The finest the view asks for, on a mesh of 22,500 patches, is a few quads per patch. + let big = mesh(150).quads(64); + assert!(big.len() <= GradientMesh::MAX_QUADS && big.len() >= 22_500, "{} quads", big.len()); + // More patches than the budget has quads is malformed, as a point count that doesn't fit is. + let point = MeshPoint { p: Point::ZERO, color: Color::rgb(0.0, 0.0, 0.0), opacity: 1.0, handles: [Vec2::ZERO; 4] }; + let huge = GradientMesh { rows: 2000, cols: 2000, points: vec![point; 2001 * 2001] }; + assert!(!huge.is_valid()); + assert!(huge.quads(1).is_empty()); + } + /// A file's rows and columns can be anything: a point count too large to compute is no match /// for the points (u32::MAX × u32::MAX wrapped to 0 and passed for a grid without points). #[test] diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index dbbfc66bdd..e86eecff9d 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -170,11 +170,22 @@ pub(super) fn grid_len(rows: u32, cols: u32) -> Option<usize> { } impl GradientMesh { + /// The most quads one tessellation ([`Self::quads`]) makes, and so the most patches a mesh can + /// have: about 170 bytes a quad, so about 170 MB. A file's mesh can have any number of patches + /// and the view any zoom; the subdivision per patch gives way before memory does, and a mesh + /// with more patches than that is malformed, like a point count that doesn't fit (#3592). + pub const MAX_QUADS: usize = 1 << 20; + pub fn idx(&self, r: usize, c: usize) -> usize { r * (self.cols as usize + 1) + c } pub fn is_valid(&self) -> bool { - self.rows >= 1 && self.cols >= 1 && grid_len(self.rows, self.cols) == Some(self.points.len()) + self.rows >= 1 && self.cols >= 1 && grid_len(self.rows, self.cols) == Some(self.points.len()) && self.patches() <= Self::MAX_QUADS + } + + /// The number of patches (rows times columns), saturating. + fn patches(&self) -> usize { + (self.rows as usize).saturating_mul(self.cols as usize) } /// A 1×1 mesh from its four corners (bottom-left, top-left, top-right, bottom-right in u/v @@ -352,8 +363,9 @@ impl GradientMesh { if !self.is_valid() { return vec![]; } - let n = n.clamp(1, 64); - let mut out = Vec::with_capacity(self.rows as usize * self.cols as usize * n * n); + // Finer than the budget allows gives way: a quad per patch is the least. + let n = n.clamp(1, 64).min(((Self::MAX_QUADS / self.patches()) as f64).sqrt() as usize).max(1); + let mut out = Vec::with_capacity(self.patches() * n * n); let mut grid = vec![Point::ZERO; (n + 1) * (n + 1)]; let mut colors = vec![(Color::default(), 0.0f32); (n + 1) * (n + 1)]; for r in 0..self.rows as usize { diff --git a/docs/gaps.md b/docs/gaps.md index 1fb18246a5..646ffa8648 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (G13: gradient presets done; G1: Average & Join with a corner or smooth choice, Cmd+Alt+Shift+J, done; estimate unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G8: a gradient mesh from a file is budgeted at about a million patches and quads, #3592; estimate unchanged; previously: G13: gradient presets done; G1: Average & Join with a corner or smooth choice, Cmd+Alt+Shift+J, done; estimate unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -224,6 +224,12 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall taken whole or not at all, and the walk that counts an art tile's anchors charges the whole budget for a tree it cannot finish, so nesting buys no room (#3594). The budget is per brushed stroke, not per document: many strokes that each reach it still add up, and a brush inside a symbol is bounded per stroke rather than per instantiation. +- **Done (2026-10-31):** a gradient mesh is budgeted (#3592). One tessellation makes at most 1,048,576 quads, and a + mesh with more patches than that (rows times columns) is malformed, like a point count that doesn't fit, so it draws + nothing instead of exhausting memory: a 150 x 150 mesh from a 3 MB file exported at 20x asked for about 23 million + quads (2 GB). Below the budget the subdivision per patch gives way a step at a time (a 60 x 60 mesh draws at 16 x 16 + quads a patch instead of 32 x 32); a small mesh is drawn as finely as before. The command's own cap of 50 rows and + columns is unchanged. - **Done (2026-10-29):** a blend makes at most 100,000 steps in all, counting the steps of the blends inside its keys and steps. Each step between keys that are blends is a copy of one, which makes its own steps when drawn, so step counts capped at 1,000 a pair multiplied by nesting: five levels of 15 steps were 1.3 million steps from a 23 KB @@ -660,6 +666,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | G8: a gradient mesh read from a file is budgeted: at most about a million quads per tessellation, and a mesh with more patches than that is malformed (#3592); estimate unchanged. | | 2026-10-30 | minor | 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 | G1: Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J) moves two open path ends to their midpoint and joins them there, the joined anchor a corner or a smooth point as its dialog's Points choice asks, in one undo step; the "Average and Join in one step" entry is done. Estimate unchanged | | 2026-10-30 | minor | G12: Preferences audit (#394): 5 options are stored but not acted on (Open Documents As Tabs, Units › East Asian Type, Hyphenation › Default Language, Scratch Disks, Legacy vector clipboard), each waiting on a subsystem; Highlight Alternate Glyphs, Real-time Drawing and the Devices options act. Estimate unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5d1ce531d2..a871b67478 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Path Precision Tools add-on: Shared Edge Merge Tool; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G8: a gradient mesh from a file is budgeted, #3592; scores unchanged; previously: Path Precision Tools add-on: Shared Edge Merge Tool; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -19,6 +19,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Hardening: a gradient mesh read from a file is budgeted at about a million quads, so a huge one no longer exhausts memory when drawn or exported (#3592); scores 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-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-30 | minor | Effects: bounded length inputs clamp on commit and reject invalid text visibly (#4638); scores unchanged. | From 2c28ccf0207ad5e70b8f755227616ac216b53149 Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Sat, 31 Oct 2026 18:55:28 +0700 Subject: [PATCH 56/98] Gradient mesh: flat pieces follow the quad budget's subdivision A mesh over the quad budget is cut coarser than asked (#3592); its flat pieces now use the same subdivision, so each patch takes its own quads. --- crates/doc/src/live.rs | 15 +++++++++++++++ crates/doc/src/live/mesh.rs | 14 ++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 102a614b93..1b234c2122 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -204,6 +204,21 @@ mod tests { assert!(huge.quads(1).is_empty()); } + /// Flat pieces of a mesh over the quad budget follow the coarser subdivision [`GradientMesh::quads`] + /// gives way to: each patch's pieces are its own quads', so together they cover the mesh once. + #[test] + fn a_large_meshs_pieces_follow_the_quad_budget() { + let m = GradientMesh::from_surface(60, 60, &|u, v| Point::new(u * 300.0, v * 300.0), &|u, v| Color::rgb(u as f32, v as f32, 0.5)); + let area = |pts: &[Point]| { + let n = pts.len(); + (0..n).map(|i| pts[i].x * pts[(i + 1) % n].y - pts[(i + 1) % n].x * pts[i].y).sum::<f64>().abs() / 2.0 + }; + let pieces = m.pieces(32); + assert!(pieces.len() >= 3600, "{} pieces", pieces.len()); + let total: f64 = pieces.iter().map(|p| area(&p.pts)).sum(); + assert!((total - 90_000.0).abs() < 90.0, "covered {total}"); + } + /// A file's rows and columns can be anything: a point count too large to compute is no match /// for the points (u32::MAX × u32::MAX wrapped to 0 and passed for a grid without points). #[test] diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index 10540266a3..f72dd548cc 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -367,13 +367,19 @@ impl GradientMesh { (Color::rgb(rgb[0], rgb[1], rgb[2]), op) } - /// Tessellate into `n`×`n` quads per patch, each coloured at its centre and its corners. + /// The quads per patch side [`Self::quads`] cuts at when `n` is asked: 1 to 64, and coarser + /// where the patches at `n` would pass [`Self::MAX_QUADS`] (a quad per patch is the least). + fn subdivision(&self, n: usize) -> usize { + n.clamp(1, 64).min(((Self::MAX_QUADS / self.patches().max(1)) as f64).sqrt() as usize).max(1) + } + + /// Tessellate into `n`×`n` quads per patch ([`Self::subdivision`]), each coloured at its centre + /// and its corners. pub fn quads(&self, n: usize) -> Vec<MeshQuad> { if !self.is_valid() { return vec![]; } - // Finer than the budget allows gives way: a quad per patch is the least. - let n = n.clamp(1, 64).min(((Self::MAX_QUADS / self.patches()) as f64).sqrt() as usize).max(1); + let n = self.subdivision(n); let mut out = Vec::with_capacity(self.patches() * n * n); let mut grid = vec![Point::ZERO; (n + 1) * (n + 1)]; let mut colors = vec![(Color::default(), 0.0f32); (n + 1) * (n + 1)]; @@ -411,7 +417,7 @@ impl GradientMesh { /// all its quads. A piece's outline runs through its quads' outer corners, so it covers what /// they covered, and it takes the colour at its centre. pub fn pieces(&self, n: usize) -> Vec<MeshPiece> { - let n = n.clamp(1, 64); + let n = self.subdivision(n); let quads = self.quads(n); let cols = (self.cols as usize).max(1); let mut out = Vec::with_capacity(quads.len()); From 59eb658fd7176a852e3d6f1b00d73e697dc5dcd4 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:24:26 +0430 Subject: [PATCH 57/98] Fix #2651: Grid Repeat Options has a Preview checkbox The grid options' public page lists a Preview option: the repeat on the canvas follows the fields while the dialog is open. Object > Repeat > Options had none, so a change showed only after OK. Now a Grid Repeat's dialog has the checkbox: Cancel puts the original back without a history step, OK keeps the previewed values as one undo step. Radial and Mirror Repeat Options are left as they are, since their public pages list no Preview. --- crates/ui-egui/src/dialogs.rs | 5 +- crates/ui-egui/src/dialogs/command.rs | 110 ++++++++++++++++++++++++-- docs/gaps.md | 4 +- docs/target-app-parity.md | 3 +- docs/ui-parity.md | 4 +- 5 files changed, 115 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ba85dc4a25..acbad17508 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -468,9 +468,10 @@ pub fn cancel(app: &mut VectorcraftApp) { missing_fonts::requeue_replaced(app); if let Some(d) = app.ui.dialog.take() { // Toolbar-only Flare options start no preview and must not cancel a gesture - // that was already active before opening them. + // 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. let owns_preview = d.kind != flare_options::KIND || d.fields.contains_key(form::PREVIEWED); - if spec(&d.kind).preview && owns_preview { + if (spec(&d.kind).preview && owns_preview) || d.fields.contains_key(form::PREVIEWED) { let _ = app.session.cancel_interaction(); } app.ui.dialog = parent(&d); diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 3ebefdc211..89ef90c883 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -16,13 +16,33 @@ pub(super) const SPEC: DialogSpec = DialogSpec { let (lengths, order) = (lengths(&command), order(&command)); let rank = |k: &str| order.iter().position(|o| *o == k).unwrap_or(order.len()); form::param_fields(ui, d, &|k| lengths.contains(&k), &|k| choices(&command, k), &rank, app.session.general_unit()); + if previews(d) { + let (cmd, params) = call(d); + form::preview(app, ui, d, &d.str("__label"), &cmd, params); + } false }, confirm, - passes: takes, + passes: |d, key| takes(d, key) || (key == "preview" && previews(d)), ..DialogSpec::FORM }; +/// Does this dialog show a Preview checkbox? Only Grid Repeat Options does: the public page for +/// the grid options lists one, and the radial and mirror pages do not. (Cancel rolls a preview +/// back whichever dialog started it.) +fn previews(d: &Dialog) -> bool { + d.str("__command") == "object.repeat.options" && d.fields.contains_key("gridType") +} + +/// The command the dialog runs and its parameters: the fields shown and the fixed ones. +fn call(d: &Dialog) -> (String, Value) { + let mut params = form::params(d); + if let (Some(o), Some(Value::Object(fixed))) = (params.as_object_mut(), d.fields.get("__params")) { + o.extend(fixed.clone()); + } + (d.str("__command"), params) +} + /// Does the dialog's command take parameter `key` (its params description names it)? fn takes(d: &Dialog, key: &str) -> bool { let id = d.str("__command"); @@ -100,12 +120,12 @@ const PIE_SORT: form::Choices = &[("All", "all"), ("First", "first"), ("None", " /// Graph Type › Tick Marks › Length. const GRAPH_TICK_LENGTH: form::Choices = &[("None", "none"), ("Short", "short"), ("Full Width", "full")]; -/// Closes before running, so a dialog the command opens stays open. +/// Closes before running, so a dialog the command opens stays open. A previewed one keeps its +/// preview as the one undo step. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> { - let cmd = d.str("__command"); - let mut params = form::params(d); - if let (Some(o), Some(Value::Object(fixed))) = (params.as_object_mut(), d.fields.get("__params")) { - o.extend(fixed.clone()); + let (cmd, params) = call(d); + if previews(d) { + return form::commit_preview(app, &cmd, params); } app.ui.dialog = None; // A tool's click-to-size shape (Flare) goes on the active perspective plane while the grid shows. @@ -192,4 +212,82 @@ mod tests { assert_eq!(app.session.execute("graph.setType", &json!({})).unwrap()["type"], "stackedBar"); assert_eq!(app.session.execute("graph.setData", &json!({})).unwrap(), data); } + + /// A document with a rectangle made a 2 x 2 Grid Repeat and selected, its Repeat Options + /// dialog open. + fn grid_options_app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("object.repeat.grid", json!({"ids": [id], "rows": 2, "cols": 2})).unwrap(); + crate::menus::invoke(&mut app, "object.repeat.options", json!({})); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, "command"); + app + } + + /// One headless frame of the open dialog. + 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| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + fn cols(app: &VectorcraftApp) -> u64 { + crate::panels::pattern_options::repeat_fields(app).unwrap()["cols"].as_u64().unwrap() + } + + /// #2651: the grid options' public page lists a Preview checkbox: with it on, the repeat on + /// the canvas follows the fields while the dialog is open; Cancel puts it back without a + /// step of history; OK keeps what was previewed as one step. + #[test] + fn grid_repeat_options_preview_on_the_canvas() { + let mut app = grid_options_app(); + let steps = app.session.doc().unwrap().history.undo.len(); + // Off: the fields change nothing until OK. + app.ui.dialog.as_mut().unwrap().fields.insert("cols".into(), json!(3)); + frame(&mut app); + assert_eq!(cols(&app), 2, "no preview without the checkbox"); + // On, the repeat follows the fields. + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "preview".into(), json!(true)); + frame(&mut app); + assert_eq!(cols(&app), 3, "the canvas shows 3 columns while the dialog is open"); + app.ui.dialog.as_mut().unwrap().fields.insert("cols".into(), json!(4)); + frame(&mut app); + assert_eq!(cols(&app), 4, "and follows the next edit"); + // Cancel puts the original back and leaves no history. + crate::dialogs::cancel(&mut app); + assert_eq!(cols(&app), 2, "Cancel restores the original repeat"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), steps, "and adds no undo step"); + assert!(!app.session.in_interaction()); + // OK keeps the previewed values as one undo step. + crate::menus::invoke(&mut app, "object.repeat.options", json!({})); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("cols".into(), json!(5)); + crate::dialogs::set_field(d, "preview".into(), json!(true)); + frame(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(cols(&app), 5); + assert_eq!(app.session.doc().unwrap().history.undo.len(), steps + 1, "one undo step"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(cols(&app), 2, "one Undo takes the whole change back"); + } + + /// Radial and Mirror Repeat Options have no Preview (their public pages list none): their + /// fields apply on OK only, and the dialog takes no `preview`. + #[test] + fn radial_repeat_options_have_no_preview() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("object.repeat.radial", json!({"ids": [id], "instances": 4})).unwrap(); + crate::menus::invoke(&mut app, "object.repeat.options", json!({})); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(!crate::dialogs::accepts_field(d, "preview")); + app.ui.dialog.as_mut().unwrap().fields.insert("instances".into(), json!(7)); + frame(&mut app); + assert_eq!(crate::panels::pattern_options::repeat_fields(&app).unwrap()["instances"], 4); + assert!(!app.session.in_interaction()); + } } diff --git a/docs/gaps.md b/docs/gaps.md index 646ffa8648..608779f7af 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G8: a gradient mesh from a file is budgeted at about a million patches and quads, #3592; estimate unchanged; previously: G13: gradient presets done; G1: Average & Join with a corner or smooth choice, Cmd+Alt+Shift+J, done; estimate unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G1: Grid Repeat Options has a Preview checkbox, #2651; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -94,6 +94,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done:** the Properties panel has the additional contextual controls of the public Properties panel overview: Blend Options in Quick Actions for one blend with the Blend tool, Merge Live Paint for a Live Paint group selected with more paths to merge (Live Paint Make and Merge refuse type, images and other objects that aren't paths, which used to vanish among the group's sources), the Width Profile list in Appearance with the Width tool or a variable-width stroke, an applied effect's Delete icon beside fx (with several effects it opens the Appearance panel), and Corner and Corner Radius for corners selected with Direct Selection (public doc cited in target-app-parity.md). Broader G1 estimates unchanged. - **Done (#2691):** a selected Grid Repeat ([public page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html)) shows a handle below the grid, one right of it and one past its bottom-right corner: dragging them adds or removes rows, columns or both (1 to 500). A slider between the first two columns and one between the first two rows set the horizontal and vertical spacing, staying halfway across the gap under the pointer. Each drag is one undo step through `object.repeat.options` and Escape cancels it; the controls hide with the edges or the bounding box, and bounding-box handles keep priority where they meet. Radial Repeat handles remain. Estimates unchanged. +- **Done (#2651):** Object › Repeat › Options… for a Grid Repeat has a Preview checkbox ([public page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html)): while it is on the repeat on the canvas follows the fields, Cancel puts the original back without a history step and OK keeps the previewed values as one undo step. Radial and Mirror Repeat Options have none, as their public pages list none. Estimate unchanged. - **Done (#2877):** a selected Mirror Repeat shows its axis: drag the line or the center handle to move it, an end handle to rotate it (Shift: 45°); each drag is one undo step and Escape cancels it. Bounding-box handles take priority over the axis line; the center and end handles keep their actions. Broader G1 estimates unchanged. - **Existing-guide Escape cancellation:** Selection and Direct/Group Selection clear pending presses and cancel begun guide previews. App-input regressions cover preselected-guide document and interaction-start selection restoration, Undo/Redo and saved state, and later on-canvas motion/release. All eight app-input checks and the Direct Selection Escape control passed in current full CI; [Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area). The broader G1 scope and estimate remain unchanged. @@ -677,6 +678,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-10-26 | minor | G9: EPS save-back: 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. This narrows G9's save-back workflow only; its import-fidelity items (layer and group structure, guides, open speed, live objects) and estimate are unchanged, and writing Illustrator's own editing data stays out of scope (#868) | | 2026-10-26 | minor | G7: View › New View… / Edit Views… ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)): the View menu lists every one of a document's 25 saved views (it listed 10), Edit Views… lists them to rename or delete in one undo step (`view.saved.edit {edits}`), and a file's views are checked when it opens; New View/Edit Views leave G7's missing list; estimate unchanged | | 2026-10-25 | minor | G4: a double-click on a threaded port with the Selection tool breaks the thread there (`text.thread.break {id, port}`): the story flows into the frames before the break, the frames after it are left empty and stay threaded with each other when two or more; a double-click on a port with no thread does nothing; estimate unchanged | +| 2026-10-31 | minor | G1: Grid Repeat Options has a Preview checkbox (#2651); estimate unchanged. | | 2026-10-25 | minor | 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); bounding-box handles keep priority where they meet; Radial Repeat handles remain; estimates unchanged. | | 2026-10-25 | minor | Floating panels open in OS windows of their own on the desktop (inside the window on the web and on Wayland); saved layouts from earlier versions convert to the shared docking layout (#4135); scores and estimates unchanged (estimated) | | 2026-10-24 | minor | Recolor Artwork › Assign: double-clicking a row's new color opens the Color Picker over the dialog; its OK sets the row's new color in the model it was picked in (a CMYK mix stays CMYK, RGB becomes CMYK in a CMYK document), Cancel leaves the row as it was. A Lab new color is kept, but a Lab color can't be typed yet: the picker's Lab fields work in RGB, as for fill and stroke (part of #4028; Lab entry, swatch libraries and the row menu remain). Scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a871b67478..b29ba24552 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G8: a gradient mesh from a file is budgeted, #3592; scores unchanged; previously: Path Precision Tools add-on: Shared Edge Merge Tool; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G1: Grid Repeat Options has a Preview checkbox, #2651; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -19,6 +19,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Patterns: Grid Repeat Options has a Preview checkbox, as the public grid options page lists (#2651); scores unchanged. | | 2026-10-31 | minor | Hardening: a gradient mesh read from a file is budgeted at about a million quads, so a huge one no longer exhausts memory when drawn or exported (#3592); scores 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-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. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3313a18d2e..6e6ed4b9b8 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Path Precision Tools add-on: Shared Edge Merge Tool; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G1: Grid Repeat Options has a Preview checkbox, #2651; scores unchanged; previously: Path Precision Tools add-on: Shared Edge Merge Tool; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -274,6 +274,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Command search: Up and Down move through the results, Enter runs the highlighted one | done | #1798 (#1757) | | Pathfinder panel: a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled | done | #1642 (#1635) | | Selected Grid Repeat shows row, column and corner handles and spacing sliders; bounding-box handles take priority where they meet | done | #2691, `tools/src/repeat_grid.rs`; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html); drags preview and commit `object.repeat.options` as one undo step, Escape cancels; Radial Repeat has no handles yet | +| Grid Repeat Options (Object › Repeat › Options…) has a Preview checkbox: the repeat on the canvas follows the fields while the dialog is open, Cancel puts the original back without a history step, OK keeps the previewed values as one undo step | done | #2651; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html) lists the Preview option for the grid options only, so Radial and Mirror Repeat Options have none; `dialogs/command.rs` tests | | Selected Mirror Repeat shows a prominent axis with move and rotation handles; bounding-box handles take priority over the axis line | done | #2877, `tools/src/mirror.rs`; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html) | | Object › Expand on radial, grid and mirror repeats makes groups of their instances | done | #1765 (#1756) | | Object › Expand… is dimmed while the selection has appearance attributes (effects, several fills or strokes, a fill or stroke with its own transparency or effects, a brush); its tooltip and the status bar say to use Object › Expand Appearance first, which leaves a stroked line with Zig Zag a stroked zig-zag line that Expand then outlines | done | #3089; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html); `dialogs/expand.rs` tests, engine `tests_objexpand`. `object.expand` (agents, scripts) still bakes effects in one step | @@ -375,6 +376,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Grid Repeat Options has a Preview checkbox (#2651); scores and estimates unchanged | | 2026-10-31 | minor | Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together; scores and estimates unchanged | | 2026-10-31 | minor | Path Precision Tools add-on: live Path Effects in Effect › Path Effects; scores and estimates unchanged | | 2026-10-31 | minor | Path Precision Tools add-on: the Corner Tool (editable corners, panel section) and Slide Anchor Along Path; scores and estimates unchanged | From f65d28378f76fca3965724ccdb3edb239662869a Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:57:02 +0430 Subject: [PATCH 58/98] Fix #995: a width-profile stroke keeps its shape when the view zooms out The canvas keeps a hairline visible by raising a stroke's weight to half a device pixel. A width profile multiplies that weight, so a 1 pt stroke with a 6x width point swelled to twice its size at 25% zoom and shrank back on zooming in. The minimum now goes to the profile's widest point instead; plain strokes are unchanged. --- crates/doc/src/appearance/profile.rs | 9 ++++ crates/render/src/renderer/paint.rs | 7 +++- crates/render/src/tests.rs | 1 + crates/render/src/tests/widthzoom.rs | 63 ++++++++++++++++++++++++++++ docs/target-app-parity.md | 4 +- 5 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 crates/render/src/tests/widthzoom.rs diff --git a/crates/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs index ccae9666bd..09142df0c0 100644 --- a/crates/doc/src/appearance/profile.rs +++ b/crates/doc/src/appearance/profile.rs @@ -53,6 +53,15 @@ impl WidthProfile { // Two swells between narrow necks. ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] }, ]; + /// The widest the stroke gets, as a multiple of its weight: the largest total of the left and + /// right factors at any of the points (between them the profile is straight), 1 for a profile + /// without points. + pub fn widest(&self) -> f64 { + if self.points.is_empty() { + return 1.0; + } + self.points.iter().map(|&(_, l, r)| (l.max(0.0) + r.max(0.0)) / 2.0).fold(0.0, f64::max) + } /// The built-in profile with this id. pub fn preset(id: &str) -> Option<Self> { Self::PRESETS.iter().find(|p| p.id == id).map(|p| Self { points: p.points.to_vec() }) diff --git a/crates/render/src/renderer/paint.rs b/crates/render/src/renderer/paint.rs index 9a6a48142b..3214ffe58d 100644 --- a/crates/render/src/renderer/paint.rs +++ b/crates/render/src/renderer/paint.rs @@ -109,8 +109,11 @@ impl Renderer { } ctx.set_transform(f.view * xf); let closed = effects::stroke::is_closed(bp); - // Keep hairlines visible when zoomed far out (at least ~1 device pixel). - let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5 / xf.determinant().abs().sqrt().max(1e-9)); + // Keep hairlines visible when zoomed far out (at least ~1 device pixel). A width profile + // multiplies the weight, so it is the profile's widest point that is kept visible: raising + // the weight further would swell the whole stroke while the view zooms out (#995). + let widest = st.profile.as_ref().map_or(1.0, |p| p.widest().max(1e-3)); + let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5 / xf.determinant().abs().sqrt().max(1e-9) / widest); let inside = st.align == StrokeAlign::Inside && closed; if inside { ctx.set_fill_rule(fill_rule(rule)); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 767f1eda8a..643701fc72 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -29,6 +29,7 @@ mod setup; mod strokegradient; mod tileedge; mod viewraster; +mod widthzoom; fn rect_node(d: &mut Document, r: Rect, fill: Paint, stroke: Paint, w: f64) -> Node { let id = d.alloc_id(); diff --git a/crates/render/src/tests/widthzoom.rs b/crates/render/src/tests/widthzoom.rs new file mode 100644 index 0000000000..a59387589f --- /dev/null +++ b/crates/render/src/tests/widthzoom.rs @@ -0,0 +1,63 @@ +//! A stroke with a width profile keeps its shape when the view zooms out (#995): only a stroke too +//! thin to see is thickened, and then by what its widest point needs, not all of it at once. + +use vectorcraft_color::Paint; +use vectorcraft_doc::{Appearance, AppearanceItem, Node, StrokeLayer, WidthProfile}; +use vectorcraft_geom::PathData; + +use crate::*; + +const SIZE: u32 = 200; + +/// A line across a 200×200 page, stroked black `weight` pt with `profile`, drawn at `scale`. +fn render(weight: f64, profile: Option<WidthProfile>, scale: f64) -> Rendered { + let mut st = StrokeLayer::new(Paint::solid(vectorcraft_color::Color::BLACK), weight); + st.profile = profile; + let mut p = BezPath::new(); + p.move_to((20.0, 100.0)); + p.line_to((180.0, 100.0)); + let mut d = Document::new(SIZE as f64, SIZE as f64); + let n = Node::path(d.alloc_id(), PathData::from_bezpath(&p), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() }); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + Renderer::new().render(&d, SIZE, SIZE, Affine::scale(scale), &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) +} + +/// Device pixels of ink in the column through the middle of the line (coverage summed, so a +/// stroke narrower than a pixel still counts what it covers). +fn ink_at_middle(img: &Rendered, scale: f64) -> f64 { + let x = (100.0 * scale) as u32; + (0..SIZE).map(|y| 1.0 - img.pixel(x, y)[0] as f64 / 255.0).sum() +} + +/// Thin at both ends, six times the weight in the middle. +fn swell() -> WidthProfile { + WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 6.0, 6.0), (1.0, 1.0, 1.0)] } +} + +#[test] +fn a_thin_width_stroke_keeps_its_thickness_in_document_terms_when_zoomed_out() { + let whole = ink_at_middle(&render(1.0, Some(swell()), 1.0), 1.0); + assert!((whole - 6.0).abs() < 0.3, "six points at full size: {whole}"); + for scale in [0.5, 0.25, 0.125] { + let ink = ink_at_middle(&render(1.0, Some(swell()), scale), scale); + let in_points = ink / scale; + assert!((in_points - whole).abs() < whole * 0.05, "at {scale}: {in_points} pt against {whole} pt"); + } +} + +#[test] +fn a_stroke_too_thin_to_see_is_kept_visible_by_its_widest_point() { + // 0.1 pt, twice that in the middle: at a quarter size even the middle is a twentieth of a + // pixel, so it is thickened to the half pixel a plain hairline gets, and the profile's ends + // stay half of it. + let thin = WidthProfile { points: vec![(0.0, 0.5, 0.5), (0.5, 1.0, 1.0), (1.0, 0.5, 0.5)] }; + let plain = ink_at_middle(&render(0.1, None, 0.25), 0.25); + let img = render(0.1, Some(thin), 0.25); + let shaped = ink_at_middle(&img, 0.25); + assert!((plain - 0.5).abs() < 0.1, "a plain hairline gets half a pixel: {plain}"); + assert!((shaped - 0.5).abs() < 0.1, "the widest point of a profile gets it too: {shaped}"); + // Seven pixels in, 28 pt along the page: just past the line's start at 20 pt. + let end: f64 = (0..SIZE).map(|y| 1.0 - img.pixel(7, y)[0] as f64 / 255.0).sum(); + assert!(end < shaped * 0.75, "the thin end stays thinner than the middle: {end} against {shaped}"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b29ba24552..5bc104bc84 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G1: Grid Repeat Options has a Preview checkbox, #2651; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (a width-profile stroke keeps its shape when the view zooms out, #995) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -19,6 +19,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Width tool: a stroke with a width profile keeps its shape while the view zooms out; the minimum visible width goes to its widest point instead of scaling the whole profile (#995); scores unchanged. | | 2026-10-31 | minor | Patterns: Grid Repeat Options has a Preview checkbox, as the public grid options page lists (#2651); scores unchanged. | | 2026-10-31 | minor | Hardening: a gradient mesh read from a file is budgeted at about a million quads, so a huge one no longer exhausts memory when drawn or exported (#3592); scores 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. | @@ -507,6 +508,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Width-point finite-arithmetic follow-up (G1/M3):** Adjoining adjustments avoid intermediate overflow; non-finite normalized or resulting factors are rejected before committing. Engine regressions in `tests/widthpoints.rs` cover finite extremes, small nonzero results, atomic rejection, Undo/Redo and native save/reopen. Historical focused validation on the `a3357121` integration observed 2 expected RED failures with 11 passing controls, then all 13 tests passing with the repair; none were ignored. All three added regressions 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. Scores and hours unchanged. - **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. +- **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). - **Video display options (#2849):** Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio, and View › Rulers › Show Video Rulers ([Set video display options for artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)). Each artboard has its own (`artboard.setProps`/`artboard.new` `showVideoSafeAreas`, `videoRulerPixelAspectRatio` 0.1–10, default 1), set in one undo step and written to the native format only when on or not 1, so other documents save byte for byte as before. The help page names the safe areas without numbers; our choice is [EBU R 95](https://tech.ebu.ch/docs/r/r095.pdf)'s (note 5): the canvas outlines the action-safe area 3.5% and the title-safe (graphics-safe) area 5% in from each edge, 1 point wide and turned with the view; exports and print leave them out. Show Video Rulers (`view.videoRulers`) shows the rulers counting the active artboard in pixels from its top-left corner, whatever the ruler origin: a pixel is as many points wide as the ratio and one point tall, so a 720 × 480 pt artboard at 0.9 reads 800 × 480. Hide Video Rulers returns them to the General unit; Shift ruler-tick snapping of new guides keeps the General unit. Regression tests: `tests/panelcmds.rs` (`artboard_video_display_options_*`, `artboard_display_marks_stay_out_of_exports`), `tests/docsetup.rs` (`an_artboard_shows_its_video_safe_areas`), `canvas.rs` (`video_rulers_count_the_artboard_in_pixels_of_its_aspect_ratio`), `tests/ruler_origin.rs` (`show_video_rulers_counts_the_active_artboard_in_pixels`), `dialogs/tests.rs` (`artboard_options_show_video_display_options`). From 63b685758f5759c4b33d46d5d8dcdb309bfa16ea Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:15:57 +0430 Subject: [PATCH 59/98] Snapping: a line end dragged with Direct Selection snaps square to straight paths and tangent to curves (#2530) --- crates/tools/src/direct.rs | 40 +++++++++++++- crates/tools/src/direct/tests.rs | 95 ++++++++++++++++++++++++++++++++ crates/tools/src/guides/snap.rs | 8 +++ docs/gaps.md | 21 +++++-- docs/target-app-parity.md | 11 ++-- docs/ui-parity.md | 5 +- 6 files changed, 166 insertions(+), 14 deletions(-) diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 623d2b5c5c..c27466935e 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -180,6 +180,10 @@ pub struct DirectSelectionTool { guides: Vec<Overlay>, /// What the dragged anchors snap to, gathered when the drag begins. anchor_snap: Option<PointSnap>, + /// With one open path's end anchor dragged along a straight segment, that segment's other end: + /// the dragged end may land square to a straight path or grazing a curve (Smart Guides › + /// Geometric Guides). Set when the drag begins. + line_end: Option<Leave>, /// What the anchor holding paths moved whole snaps to while Alt makes a copy: the original left /// behind too. Gathered with [`Self::anchor_snap`], before the previews move the original. copy_snap: Option<PointSnap>, @@ -227,6 +231,7 @@ impl DirectSelectionTool { mesh: MeshEdit::default(), guides: vec![], anchor_snap: None, + line_end: None, copy_snap: None, handle_snap: HandleSnap::default(), move_snap: None, @@ -406,6 +411,38 @@ fn anchor_owners(cx: &ToolContext, f: fn(&Node) -> bool) -> Vec<NodeId> { v } +/// Where a line's end anchor `grab`bed for a drag leaves from, when it is one: the only anchor +/// selected, the end of an open subpath whose one segment is straight, and no other art moving +/// with it. That segment's other end stays put, so the end snaps square to straight paths and +/// grazing curves; mid-line anchors and ends of curved segments do not (Illustrator limits Snap +/// to Tangent and Snap to Perpendicular to line endpoints). +fn line_end_leave(cx: &ToolContext, grab: Point) -> Option<Leave> { + let sel = cx.selection; + if !sel.segments.is_empty() || sel.anchors.len() != 1 { + return None; + } + let (&id, set) = sel.anchors.iter().next()?; + if set.len() != 1 || sel.objects.iter().any(|o| *o != id) { + return None; + } + let &(si, ai) = set.iter().next()?; + let sp = cx.doc.node(id)?.path_data()?.subpaths.get(si)?; + let n = sp.anchors.len(); + if sp.closed || ai >= n || sp.anchors[ai].p.distance(grab) > 1e-6 { + return None; + } + let (other, seg) = if n < 2 { + return None; + } else if ai == 0 { + (1, 0) + } else if ai == n - 1 { + (n - 2, n - 2) + } else { + return None; + }; + sp.segment_is_line(seg).then(|| Leave::contact(sp.anchors[other].p)) +} + /// The anchor within `tol` of `p` nearest to it, of the topmost selected path or area type frame /// that has one, else of the topmost visible one: (id, si, ai, where it is). Selected objects come /// first so an object above can't take the press from the one being edited (#1508), as @@ -1230,6 +1267,7 @@ impl DirectSelectionTool { out.push(Action::Begin("Move".into())); self.state = State::MoveAnchors { start, grab, began: true }; self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); + self.line_end = line_end_leave(cx, grab); } let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle); let unsnapped = d; @@ -1238,7 +1276,7 @@ impl DirectSelectionTool { self.guides.clear(); if let Some(snap) = &self.anchor_snap { let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true)); - let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref()); + let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref().or(self.line_end.as_ref())); (d, self.guides) = (q - grab, guides); } self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides); diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index d3f394d99f..cb852c5531 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -1477,6 +1477,101 @@ fn dragged_end_of_a_slanted_line_stays_on_its_direction() { assert!(matches!(guides[..], [(a, b)] if a == Point::new(300.0, 400.0) && (b - Point::new(420.0, 310.0)).hypot() < 1e-9), "{guides:?}"); } +/// Drawn lines snap square to a straight path and grazing a curve (Smart Guides › Geometric +/// Guides); so does an existing line whose end anchor is dragged with Direct Selection. Each +/// option switches its own snap off. Only a line's end snaps so: the middle anchor of a +/// polyline, an end of a curved segment, a closed path's anchor, several anchors dragged together, +/// a Shift drag and an end out of reach do not, nor does Group Selection, which moves the path whole. +#[test] +fn dragged_line_ends_snap_perpendicular_to_segments_and_tangent_to_curves() { + use vectorcraft_geom::{BezPath, PathData, SubPath, shapes}; + let near = |v: &serde_json::Value, (x, y): (f64, f64), eps: f64| (v["dx"].as_f64().unwrap() - x).hypot(v["dy"].as_f64().unwrap() - y) < eps; + // The rect's right side (x = 200) and a circle (centre (500, 500), radius 50), with one more + // path: its id, and the document. + let with = |path: PathData| { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let (circle, id) = (d.alloc_id(), d.alloc_id()); + let ring = + vectorcraft_doc::Node::path(circle, shapes::ellipse(Rect::new(450.0, 450.0, 550.0, 550.0)), vectorcraft_doc::Appearance::default_art()); + d.insert(Some(l), 1, ring).unwrap(); + d.insert(Some(l), 2, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap(); + (d, id) + }; + let p = paint(); + let none = Mods::default(); + let mut t = DirectSelectionTool::new(false); + // A line (300, 160)–(260, 170), its end dragged to 3.6 pt from the foot of the perpendicular + // from (300, 160) to the rect's right side: (200, 160). + let (d, id) = with(shapes::line(Point::new(300.0, 160.0), Point::new(260.0, 170.0))); + let end = anchors_of(id, &[(0, 1)]); + let c = cx(&d, &end, &p); + let (v, labels) = drag(&mut t, &c, (260.0, 170.0), (202.0, 163.0), none); + assert!(near(&v, (-60.0, -10.0), 1e-6) && labels == ["perpendicular"], "{v} {labels:?}"); + // The other end snaps the same way about this one: square to the side at (200, 170). + let start = anchors_of(id, &[(0, 0)]); + let c = cx(&d, &start, &p); + let (v, labels) = drag(&mut t, &c, (300.0, 160.0), (202.0, 172.0), none); + assert!(near(&v, (-100.0, 10.0), 1e-6) && labels == ["perpendicular"], "{v} {labels:?}"); + // Perpendicular off: the end only lands on the path, where the pointer is; tangent off does + // not change that. + let c = ToolContext { snap_perpendicular: false, ..cx(&d, &end, &p) }; + let (v, labels) = drag(&mut t, &c, (260.0, 170.0), (202.0, 163.0), none); + assert!(near(&v, (-60.0, -7.0), 1e-6) && labels == ["path"], "{v} {labels:?}"); + let c = ToolContext { snap_tangent: false, ..cx(&d, &end, &p) }; + assert_eq!(drag(&mut t, &c, (260.0, 170.0), (202.0, 163.0), none).1, ["perpendicular"]); + // With Smart Guides off, Snap to Point alone finds no contact. + let c = ToolContext { smart_guides: false, ..cx(&d, &end, &p) }; + assert_eq!(drag(&mut t, &c, (260.0, 170.0), (202.0, 163.0), none).1, Vec::<String>::new()); + // Out of reach: left alone. + let c = cx(&d, &end, &p); + let (v, labels) = drag(&mut t, &c, (260.0, 170.0), (240.0, 140.0), none); + assert!(near(&v, (-20.0, -30.0), 1e-9) && labels.is_empty(), "{v} {labels:?}"); + // Shift holds the move to its 45° step: no contact. + let shift = Mods { shift: true, ..none }; + assert!(!drag(&mut t, &c, (260.0, 170.0), (202.0, 163.0), shift).1.contains(&"perpendicular".to_string())); + // Group Selection moves the path whole: no contact either. + let mut group = DirectSelectionTool::new(true); + assert!(!drag(&mut group, &c, (260.0, 170.0), (202.0, 163.0), none).1.contains(&"perpendicular".to_string())); + + // A line (400, 500)–(420, 470) toward the circle: its end lands where the line from (400, 500) + // grazes it, at (475, 456.7). + let (d, id) = with(shapes::line(Point::new(400.0, 500.0), Point::new(420.0, 470.0))); + let end = anchors_of(id, &[(0, 1)]); + let c = cx(&d, &end, &p); + let (v, labels) = drag(&mut t, &c, (420.0, 470.0), (477.0, 459.0), none); + assert!(near(&v, (55.0, 500.0 - 1875f64.sqrt() - 470.0), 0.1) && labels == ["tangent"], "{v} {labels:?}"); + let c = ToolContext { snap_tangent: false, ..cx(&d, &end, &p) }; + let (v, labels) = drag(&mut t, &c, (420.0, 470.0), (477.0, 459.0), none); + assert!(near(&v, (55.53, -13.61), 0.01) && labels == ["path"], "the end only lands on the circle: {v} {labels:?}"); + let c = ToolContext { snap_perpendicular: false, ..cx(&d, &end, &p) }; + assert_eq!(drag(&mut t, &c, (420.0, 470.0), (477.0, 459.0), none).1, ["tangent"]); + + // Not a line's end: the middle anchor of a polyline, and an anchor of a closed path. + let three = [Point::new(300.0, 160.0), Point::new(260.0, 170.0), Point::new(240.0, 230.0)]; + let (d, id) = with(PathData::single(SubPath::polyline(&three, false))); + let mid = anchors_of(id, &[(0, 1)]); + let c = cx(&d, &mid, &p); + assert_eq!(drag(&mut t, &c, (260.0, 170.0), (202.0, 163.0), none).1, ["path"]); + let (d, id) = with(PathData::single(SubPath::polyline(&three, true))); + let corner = anchors_of(id, &[(0, 1)]); + let c = cx(&d, &corner, &p); + assert_eq!(drag(&mut t, &c, (260.0, 170.0), (202.0, 163.0), none).1, ["path"]); + // The end of a curved segment: no contact either. + let mut curve = BezPath::new(); + curve.move_to(Point::new(300.0, 160.0)); + curve.curve_to(Point::new(290.0, 150.0), Point::new(270.0, 160.0), Point::new(260.0, 170.0)); + let (d, id) = with(PathData::from_bezpath(&curve)); + let end = anchors_of(id, &[(0, 1)]); + let c = cx(&d, &end, &p); + assert_eq!(drag(&mut t, &c, (260.0, 170.0), (202.0, 163.0), none).1, ["path"]); + // A line end dragged with another anchor selected: the pair moves rigidly, no contact. + let (d, id) = with(shapes::line(Point::new(300.0, 160.0), Point::new(260.0, 170.0))); + let both = anchors_of(id, &[(0, 0), (0, 1)]); + let c = cx(&d, &both, &p); + assert!(!drag(&mut t, &c, (260.0, 170.0), (202.0, 163.0), none).1.contains(&"perpendicular".to_string())); +} + /// Group Selection (and Direct Selection on a whole path) moves objects snapping as the /// Selection tool does: B's left side lines up with A's right one. #[test] diff --git a/crates/tools/src/guides/snap.rs b/crates/tools/src/guides/snap.rs index 14a36b4223..4766455f88 100644 --- a/crates/tools/src/guides/snap.rs +++ b/crates/tools/src/guides/snap.rs @@ -230,6 +230,14 @@ impl Leave { self } + /// The straight segment from the anchor `at` to a point being dragged (an open path's end + /// moved with Direct Selection): the point may land square to a segment or grazing a curve, + /// but is not held to Shift's angles or the construction guides, which a moved anchor + /// does not follow. + pub fn contact(at: Point) -> Self { + Self { at, shift: None, angles: &[], contact: true } + } + /// A box dragged from its corner or centre `at` (Rectangle, Ellipse, Arc, the grids, and /// Artboard, which passes no Shift): Shift keeps the dragged corner on a diagonal, the box a /// square. diff --git a/docs/gaps.md b/docs/gaps.md index 608779f7af..16da466298 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G1: Grid Repeat Options has a Preview checkbox, #2651; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G13: Direct Selection snaps a dragged line end square to straight paths and grazing curves; G13: gradient presets done; G1: Average & Join with a corner or smooth choice, Cmd+Alt+Shift+J, done; estimate unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -465,15 +465,23 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall perceptual mix with dither's noise (an image with the noise in SVG and PDF); a stroke has no noise. - **Missing (asked for by users, from earlier versions):** radial gradients in Image Trace's Gradients option, which traces linear ones only (draft #1849). Inside the estimate below. -- **Partial (#2530):** tangent snapping to curves and perpendicular snapping to straight target - segments for drawn lines. Controls are saved under Preferences › Smart Guides › Geometric Guides - and available from Control bar › Snapping; both default to on. Existing-line endpoint editing and - other tool cases remain unverified. [Public source](https://helpx.adobe.com/illustrator/desktop/measure-and-align/plot-and-measure/snap-to-perpendicular.html). +- **Done (#2530):** tangent snapping to curves and perpendicular snapping to straight target + segments, as the public [perpendicular](https://helpx.adobe.com/illustrator/desktop/measure-and-align/plot-and-measure/snap-to-perpendicular.html) + and [tangent](https://helpx.adobe.com/illustrator/desktop/measure-and-align/plot-and-measure/snap-to-tangent.html) + pages describe, for the Line Segment and Pen tools' new lines and for existing lines: dragging the end + anchor of an open path's straight segment with Direct Selection lands it square to + another straight path (the foot of the perpendicular from the segment's other end) or grazing a curve, with + the "perpendicular" or "tangent" label (and the square-corner mark) Smart Guides show for drawn lines. As + the pages say, only line endpoints snap: the middle anchor of a path, the end of a curved segment, a closed + path's anchor, several anchors dragged together and Shift-constrained drags (which keep their angle step) do + not, and a curve is never a perpendicular target. Controls are saved under Preferences › Smart Guides › + Geometric Guides and available from Control bar › Snapping; both default to on. Tests: + `dragged_line_ends_snap_perpendicular_to_segments_and_tangent_to_curves` in `crates/tools/src/direct/tests.rs`. - **Done (2026-10-30):** gradient presets (30.0): the Gradient panel's Presets menu applies one of the built-in gradient libraries' presets (by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the gradient as a preset (kept with the preferences) and deletes saved ones; `gradient.presets.*` commands. -- **Missing:** remaining tangent/perpendicular snapping cases and the consolidated +- **Missing:** the consolidated snapping options (30.0); artboard colors in exports and print, and managing artboards from the canvas other than locking and unlocking them from the Artboard tool's right-click menu (30.0); the Blend panel with ease in/out and separate colour acceleration (30.7); @@ -667,6 +675,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | G13: Direct Selection snaps the end of an existing straight line, dragged by its end anchor, square to straight paths and grazing curves (Smart Guides › Geometric Guides: Snap to Perpendicular and Snap to Tangent), the case the public Snap to Perpendicular and Snap to Tangent pages add to drawing with the Line Segment and Pen tools (#2530). Scores and estimates unchanged. | | 2026-10-31 | minor | G8: a gradient mesh read from a file is budgeted: at most about a million quads per tessellation, and a mesh with more patches than that is malformed (#3592); estimate unchanged. | | 2026-10-30 | minor | 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 | G1: Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J) moves two open path ends to their midpoint and joins them there, the joined anchor a corner or a smooth point as its dialog's Points choice asks, in one undo step; the "Average and Join in one step" entry is done. Estimate unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5bc104bc84..0f2883ae6b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (a width-profile stroke keeps its shape when the view zooms out, #995) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G13: Direct Selection snaps a dragged line end square to straight paths and grazing curves; Path Precision Tools add-on: Shared Edge Merge Tool; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -141,10 +141,10 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), guides lost (#779), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC/JPEG 2000 import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; 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. | | View & navigation | 3 | 70% | multiple windows and Arrange | 9–14 | estimated; 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 Arrange | 9–14 | estimated | -| Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent snapping to curves and perpendicular snapping to straight segments for lines drawn with the Pen, Curvature and Line Segment tools (Preferences › Smart Guides › Geometric Guides and Control bar › Snapping, #2530; other tool and drag cases remain), "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); new ruler-guide Alt orientation implemented; video rulers (View › Rulers › Show Video Rulers, #2849) done, score unchanged | -| Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent snapping to curves and perpendicular snapping to straight segments for lines drawn with the Pen, Curvature and Line Segment tools (Preferences › Smart Guides › Geometric Guides and Control bar › Snapping, #2530; other tool and drag cases remain), "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), global/artboard ruler mode switch, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented; video rulers (View › Rulers › Show Video Rulers, #2849) done, score unchanged | -| Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent snapping to curves and perpendicular snapping to straight segments for lines drawn with the Pen, Curvature and Line Segment tools (Preferences › Smart Guides › Geometric Guides and Control bar › Snapping, #2530; other tool and drag cases remain), "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented; status-bar X/Y uses the active ruler origin and General units | -| Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent snapping to curves and perpendicular snapping to straight segments for lines drawn with the Pen, Curvature and Line Segment tools (Preferences › Smart Guides › Geometric Guides and Control bar › Snapping, #2530; other tool and drag cases remain), "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), global/artboard ruler mode switch and video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented; status-bar X/Y uses the active ruler origin and General units | +| Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent snapping to curves and perpendicular snapping to straight segments for lines drawn with the Pen, Curvature and Line Segment tools and for an existing line's end dragged with Direct Selection (Preferences › Smart Guides › Geometric Guides and Control bar › Snapping, #2530; the public pages name no other tool), "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); new ruler-guide Alt orientation implemented; video rulers (View › Rulers › Show Video Rulers, #2849) done, score unchanged | +| Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent snapping to curves and perpendicular snapping to straight segments for lines drawn with the Pen, Curvature and Line Segment tools and for an existing line's end dragged with Direct Selection (Preferences › Smart Guides › Geometric Guides and Control bar › Snapping, #2530; the public pages name no other tool), "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), global/artboard ruler mode switch, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented; video rulers (View › Rulers › Show Video Rulers, #2849) done, score unchanged | +| Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent snapping to curves and perpendicular snapping to straight segments for lines drawn with the Pen, Curvature and Line Segment tools and for an existing line's end dragged with Direct Selection (Preferences › Smart Guides › Geometric Guides and Control bar › Snapping, #2530; the public pages name no other tool), "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented; status-bar X/Y uses the active ruler origin and General units | +| Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent snapping to curves and perpendicular snapping to straight segments for lines drawn with the Pen, Curvature and Line Segment tools and for an existing line's end dragged with Direct Selection (Preferences › Smart Guides › Geometric Guides and Control bar › Snapping, #2530; the public pages name no other tool), "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), global/artboard ruler mode switch and video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented; status-bar X/Y uses the active ruler origin and General units | | 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 | @@ -1361,6 +1361,7 @@ add-on. | Date | Change | Summary | |---|---|---| +| 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 | Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path; evidence added; scores unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6e6ed4b9b8..73e52f87a7 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G1: Grid Repeat Options has a Preview checkbox, #2651; scores unchanged; previously: Path Precision Tools add-on: Shared Edge Merge Tool; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Direct Selection snaps a dragged line end square to straight paths and grazing curves; Path Precision Tools add-on: Shared Edge Merge Tool; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -152,7 +152,7 @@ tools, never checked side by side) isn't captured row by row. | Snapping tolerance preference | done | | | Snap to Point, Snap to Grid, Snap to Pixel; Pixel Preview | done | #1840: with Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked (#1816 waits for the reporter) | | Snap to Glyph | done | View › Snap to Glyph (`view.snapToGlyph`); new type and drawing snap to live-text baselines and glyph bounds when Smart Guides are on (#3124). Character panel menu › Show Snap to Glyph Options: a checkbox for each guide (Baseline, x-height, Glyph Bounds, Proximity Guides, Angular Guides, Anchor Point), dimmed while Snap to Glyph is off (#4232) | -| Tangent and perpendicular snapping for line endpoints (new in 30.0) | partial | #2530: tangent snapping to curves and perpendicular snapping to straight segments for drawn lines; saved Preferences › Smart Guides › Geometric Guides controls, on by default. Other tool/drag cases remain. [Public source](https://helpx.adobe.com/illustrator/desktop/measure-and-align/plot-and-measure/snap-to-perpendicular.html) | +| Tangent and perpendicular snapping for line endpoints (new in 30.0) | done | #2530: tangent snapping to curves and perpendicular snapping to straight segments for lines drawn with the Line Segment, Pen and Curvature tools, and for an existing line whose end anchor is dragged with Direct Selection (the only anchor selected, an open path's end on a straight segment; Shift keeps its angle step instead; Group Selection moves the path whole and does not snap so); mid-line anchors, curved segments' ends and curves as perpendicular targets are excluded, as the pages say. Saved Preferences › Smart Guides › Geometric Guides controls, on by default. [Perpendicular](https://helpx.adobe.com/illustrator/desktop/measure-and-align/plot-and-measure/snap-to-perpendicular.html) and [tangent](https://helpx.adobe.com/illustrator/desktop/measure-and-align/plot-and-measure/snap-to-tangent.html) pages; test `dragged_line_ends_snap_perpendicular_to_segments_and_tangent_to_curves` | | Snapping panel/popover consolidating the snap options (30.0) | partial | Control bar › Snapping provides quick access through existing view commands and prefs.set; no View › Snapping submenu. Floating-panel parity remains unverified. | | Customisable rotation snap angle (#973) | missing | Illustrator: Constrain Angle and construction angles only | | Ruler guides: drag out, select, move, delete, lock, Make/Release Guides | done | #414, #451; marquee selects unlocked ruler guides | @@ -376,6 +376,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Smart guides and snapping: Snap to Perpendicular and Snap to Tangent for an existing line's end dragged with Direct Selection (#2530), so the tangent and perpendicular row is done; scores and estimates unchanged | | 2026-10-31 | minor | Grid Repeat Options has a Preview checkbox (#2651); scores and estimates unchanged | | 2026-10-31 | minor | Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together; scores and estimates unchanged | | 2026-10-31 | minor | Path Precision Tools add-on: live Path Effects in Effect › Path Effects; scores and estimates unchanged | From acb5a9721b7e3fc0d847e572217b93ef259d4ae5 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:23:05 +0300 Subject: [PATCH 60/98] pathops: simplify the add-anchors segment check for clippy's nonminimal_bool Pre-existing lint on main that stops cargo xtask ci at clippy; the same one-line change other open PRs carry. --- crates/pathops/src/addons/pathtools.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/pathops/src/addons/pathtools.rs b/crates/pathops/src/addons/pathtools.rs index 8e0474c9a8..b98778cc56 100644 --- a/crates/pathops/src/addons/pathtools.rs +++ b/crates/pathops/src/addons/pathtools.rs @@ -185,7 +185,7 @@ pub fn add_anchors(sp: &SubPath, which: SegmentKind, spacing: AnchorSpacing, cap }; let c = sp.segment(seg); let len = c.arclen(ACCURACY); - if !picked || !(len > TINY && len.is_finite()) { + if !(picked && len > TINY && len.is_finite()) { continue; } let (count, step) = match spacing { From c7dc443ee14a53f2ebdc94aa98fae780ca7a52a0 Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:30:54 +0200 Subject: [PATCH 61/98] SVG import: a feConvolveMatrix order that overflows usvg's multiply is refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit usvg 0.48 casts the filter's order attribute through i32, so a value like 1066570220609 saturates and the u32 order_x * order_y multiply overflows. Only debug and overflow-checks builds panic on that; release builds already import the file — the multiply wraps, and VectorCraft drops the filter as an unsupported primitive with a warning, so nothing reaches the document, render or export (observed on main in a --release build: both repro files import without error; with a one-entry kernelMatrix the wrapped product 2147483647² mod 2³² = 1 even passes usvg's length check and leaves a filter claiming a 2147483647×2147483647 kernel, still dropped downstream). The import now parses order the way usvg does (svgtypes numbers, the same as-i32 cast with its saturation, the first side defaulting the second, both positive or both back to the 3×3 default) and refuses exactly the one case: when the product of the two sides overflows u32 (checked_mul). Any order usvg can multiply keeps importing as before, however large on its own, and a missing, unparseable or non-positive order still falls back to 3. Regression tests cover the overflow repros, the release wrap case, large but fitting orders (100, 80 80, 65535 65535, 2147483647 1), the pass-through shapes, and sane kernels, in debug and in release. Co-Authored-By: GLM-5.3-Flash <noreply@z.ai> --- crates/svg/src/import.rs | 41 ++++++++++++++ crates/svg/tests/convolve_overflow.rs | 82 +++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 crates/svg/tests/convolve_overflow.rs diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index ff2e63c357..18f4f088d6 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -62,6 +62,44 @@ fn parse(svg: &str) -> Result<roxmltree::Document<'_>, SvgError> { .map_err(|e| SvgError::Parse(e.to_string())) } +const SVG_NS: &str = "http://www.w3.org/2000/svg"; + +/// Refuse the one `feConvolveMatrix` usvg cannot handle: an `order` whose two sides overflow the +/// `order_x * order_y` u32 multiply its parser makes (`usvg-0.48.1/src/parser/filter.rs:673`, and +/// `tree/filter.rs:458` through `ConvolveMatrixData::new`). usvg parses `order` through f64 and +/// `as i32`, so a large finite value like `1066570220609` saturates to i32::MAX — positive, so it +/// passes the positivity guard — and the multiply overflows: a debug or overflow-checks build +/// panics while opening the file. A release build wraps instead, and with a one-entry +/// `kernelMatrix` the wrapped product passes usvg's length check, leaving a filter that claims a +/// 2147483647×2147483647 kernel (which VectorCraft then drops as an unsupported filter primitive, +/// so nothing reaches the document). Any order usvg can multiply — however large — keeps +/// importing exactly as before: a missing, unparseable or non-positive `order` falls back to 3, +/// and a `kernelMatrix` that fails to parse or match the order leaves the filter empty, which +/// usvg drops. The attribute read here is the one usvg reads: unprefixed (XML keeps unprefixed +/// attributes outside any namespace), on an element in the SVG namespace, with entities resolved +/// by the parse above. +fn convolve_matrices_ok(xml: &roxmltree::Document) -> Result<(), SvgError> { + for fe in xml.descendants() { + if !fe.has_tag_name((SVG_NS, "feConvolveMatrix")) { + continue; + } + if let Some(value) = fe.attribute("order") { + // The parse usvg does: svgtypes numbers, `as i32` (saturation included), the first + // side defaulting the second, and both positive or both back to the 3×3 default. + let side = |n: Option<Result<f64, svgtypes::Error>>, default: i32| n.and_then(|v| v.ok()).map(|v| v as i32).unwrap_or(default); + let mut numbers = svgtypes::NumberListParser::from(value); + let x = side(numbers.next(), 3); + let y = side(numbers.next(), x); + if x > 0 && y > 0 && (x as u32).checked_mul(y as u32).is_none() { + return Err(SvgError::Parse(format!( + "a feConvolveMatrix filter's order is {x}×{y}, which overflows the kernel-size multiply usvg makes" + ))); + } + } + } + Ok(()) +} + /// The document SVG text `svg` holds, read with `opts`, and the warnings about what it couldn't keep. pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Result<(Document, Vec<String>), SvgError> { // Before anything reads the tree: the XML reader, usvg, and what is made of its tree, all by @@ -81,6 +119,9 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul Cow::Borrowed(_) => xml, Cow::Owned(edited) => parse(edited)?, }; + // Before usvg sees the XML: its parser overflows a hostile feConvolveMatrix's order + // (`convolve_matrices_ok` below), and there is no panic guard to fall back on. + convolve_matrices_ok(&xml)?; let svg = svg.as_ref(); let units = RootUnits::of(xml.root_element()); let root_name = root_layer_name(xml.root_element()); diff --git a/crates/svg/tests/convolve_overflow.rs b/crates/svg/tests/convolve_overflow.rs new file mode 100644 index 0000000000..4c080e1944 --- /dev/null +++ b/crates/svg/tests/convolve_overflow.rs @@ -0,0 +1,82 @@ +//! A `<feConvolveMatrix>` whose `order` overflows usvg's `order_x * order_y` u32 multiply must be +//! refused before usvg sees it: usvg 0.48 parses the attribute through f64 and casts it with +//! `as i32`, so a large value like `1066570220609` saturates to i32::MAX and the multiply +//! overflows (a debug or overflow-checks build panics). A release build wraps instead, and with a +//! one-entry `kernelMatrix` the wrapped product passes usvg's length check, leaving a filter that +//! claims a 2147483647×2147483647 kernel. Everything else is usvg's pass-through and must keep +//! importing, however large the order is on its own. +// 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 vectorcraft_svg::import; + +/// An overflowing `order` with a `kernelMatrix` that parses (usvg `parser/filter.rs`). +#[test] +fn convolve_matrix_overflowing_order_with_kernel_is_refused() { + let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><filter id="f"><feConvolveMatrix kernelMatrix="1 2 3 4" order="1066570220609 17"/></filter><rect width="4" height="4" filter="url(#f)"/></svg>"#; + let r = import(svg); + assert!(r.is_err(), "an order that overflows the multiply must be refused, not imported"); +} + +/// The release-wrap case: a one-entry `kernelMatrix`, whose wrapped `order_x * order_y` product +/// (2147483647² mod 2³² = 1) passes usvg's length check. +#[test] +fn convolve_matrix_overflowing_order_with_wrapping_kernel_is_refused() { + let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><filter id="f"><feConvolveMatrix order="2147483647" kernelMatrix="1"/></filter><rect width="4" height="4" filter="url(#f)"/></svg>"#; + let r = import(svg); + assert!(r.is_err(), "an overflowing order must be refused even when a kernel would wrap past the length check"); +} + +/// An `order` whose second side fails to parse defaults it to the first (svgtypes rejects +/// out-of-range numbers like `-1e999`), so this is huge on both sides and refused too. +#[test] +fn convolve_matrix_overflowing_order_with_defaulted_second_side_is_refused() { + let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><filter id="f"><feConvolveMatrix kernelMatrix="-60173 1e-999 1e999 0" order="946183596582 -1e999" divisor="-1e999"/></filter><rect width="4" height="4" filter="url(#f)"/></svg>"#; + let r = import(svg); + assert!(r.is_err(), "an order that overflows the multiply must be refused, not imported"); +} + +/// An order usvg can multiply keeps importing, however large on its own: only the overflowing +/// product is refused. +#[test] +fn convolve_matrix_large_but_fitting_order_still_imports() { + for order in ["100", "80 80", "65535 65535", "2147483647 1"] { + let svg = format!( + r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><filter id="f"><feConvolveMatrix order="{order}"/></filter><rect width="4" height="4" filter="url(#f)"/></svg>"# + ); + assert!(import(&svg).is_ok(), "order={order} fits the u32 multiply and must keep importing"); + } +} + +/// usvg falls back to 3 when an order doesn't parse or isn't positive: those keep importing. +#[test] +fn convolve_matrix_nonpositive_or_unparseable_order_still_imports() { + let kernel = r#"kernelMatrix="0 0 0 0 1 0 0 0 0""#; + for order in ["0", "-3", "-1e999", "nan"] { + let svg = format!( + r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><filter id="f"><feConvolveMatrix order="{order}" {kernel}/></filter><rect width="4" height="4" filter="url(#f)"/></svg>"# + ); + assert!(import(&svg).is_ok(), "order={order} is usvg's pass-through and must keep importing"); + } +} + +/// A `kernelMatrix` that doesn't parse or doesn't match the order leaves the filter empty, which +/// usvg drops: the file keeps importing (the filter is drawn without, as a warning). +#[test] +fn convolve_matrix_unparseable_or_mismatched_kernel_still_imports() { + let mismatched = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><filter id="f"><feConvolveMatrix order="3" kernelMatrix="0 0 0 0 1 0 0 0"/></filter><rect width="4" height="4" filter="url(#f)"/></svg>"#; + assert!(import(mismatched).is_ok(), "a kernel of the wrong length is usvg's pass-through"); + let unparseable = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><filter id="f"><feConvolveMatrix order="3" kernelMatrix="1e999 0 0 0 0 0 0 0 0"/></filter><rect width="4" height="4" filter="url(#f)"/></svg>"#; + assert!(import(unparseable).is_ok(), "an unparseable kernel is usvg's pass-through"); +} + +/// Sane kernels keep importing: 3×3, 2×2, and a defaulted order. +#[test] +fn sane_convolve_matrices_still_import() { + let three = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><filter id="f"><feConvolveMatrix order="3" kernelMatrix="0 0 0 0 1 0 0 0 0"/></filter><rect width="4" height="4" filter="url(#f)"/></svg>"#; + assert!(import(three).is_ok(), "an ordinary filter must still import"); + let two = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><filter id="f"><feConvolveMatrix order="2" kernelMatrix="0 1 1 0"/></filter><rect width="4" height="4" filter="url(#f)"/></svg>"#; + assert!(import(two).is_ok(), "a small kernel must still import"); + let defaulted = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><filter id="f"><feConvolveMatrix kernelMatrix="0 0 0 0 1 0 0 0 0"/></filter><rect width="4" height="4" filter="url(#f)"/></svg>"#; + assert!(import(defaulted).is_ok(), "a filter without order must still import"); +} From 1883e5342da72d6c77c885ef5245d42b7f74ec67 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:44:34 +0300 Subject: [PATCH 62/98] PDF import: find installed fonts named with hyphens or a composite font's CMap - A composite (Type 0) font is noted by its descendant CIDFont's BaseFont (ISO 32000-1 9.7.4.1), not its own BaseFont, which ends in "-" and the CMap name (9.7.6.1, e.g. Name-Identity-H); without one, the CMap name is taken off the end of the BaseFont. - A font name's family is the longest part before a hyphen (or the comma of Arial,Bold) that names an available family, so Noto-Sans-Bold is Noto Sans, Bold and Noto-Sans-CJK-JP is Noto Sans CJK JP; a shorter family still matches when no longer one is available. - A missing font is split only at a comma or before the first part that is a style word (Helvetica-BoldOblique); otherwise the whole name is its family (MS-PGothic is reported as MS PGothic, not MS). Synthetic tests (no host fonts): Families::of builds the family list. # Conflicts: # docs/file-format-parity.md # docs/gaps.md # docs/type-parity.md --- crates/pdf/src/import_scan.rs | 22 ++++++- crates/pdf/src/import_text.rs | 81 +++++++++++++++++++++---- crates/pdf/src/tests/import_fidelity.rs | 68 +++++++++++++++++++++ docs/file-format-parity.md | 5 +- docs/gaps.md | 4 +- docs/type-parity.md | 3 +- 6 files changed, 165 insertions(+), 18 deletions(-) diff --git a/crates/pdf/src/import_scan.rs b/crates/pdf/src/import_scan.rs index dac1a7b644..9263bcd5a3 100644 --- a/crates/pdf/src/import_scan.rs +++ b/crates/pdf/src/import_scan.rs @@ -634,11 +634,11 @@ impl Walker<'_, '_> { fn note_fonts(&mut self, res: &Resources<'_>) { for key in res.fonts.keys() { if let Some(f) = res.fonts.get::<Dict<'_>>(key.as_ref()) - && let Some(name) = f.get::<Name<'_>>(b"BaseFont") + && let Some(name) = font_name(&f) { self.fonts.entry(f.cache_key()).or_insert_with(|| { let embedded = embeds_glyphs(&f); - FontNote { name: String::from_utf8_lossy(name.as_ref()).into_owned(), embedded, vertical: vertical_metrics(&f, embedded) } + FontNote { name, embedded, vertical: vertical_metrics(&f, embedded) } }); } } @@ -1009,6 +1009,24 @@ fn cid_ranges(a: &Array<'_>, per: usize, mut each: impl FnMut(u32, &[f64])) { } } +/// The PostScript name of font `f`: its `BaseFont`. A composite (Type 0) font's `BaseFont` is its +/// CIDFont's name, a hyphen and its CMap's (`Name-Identity-H`, ISO 32000-1 §9.7.6.1), so its +/// descendant CIDFont's `BaseFont` is taken, the font program's own name (§9.7.4.1); without one, +/// the `BaseFont` without the CMap's name on its end. +fn font_name(f: &Dict<'_>) -> Option<String> { + let base = String::from_utf8_lossy(f.get::<Name<'_>>(b"BaseFont")?.as_ref()).into_owned(); + if f.get::<Name<'_>>(b"Subtype").is_none_or(|s| s.as_ref() != b"Type0") { + return Some(base); + } + let descendant = f.get::<Array<'_>>(b"DescendantFonts").and_then(|a| a.iter::<Dict<'_>>().next()); + if let Some(name) = descendant.and_then(|d| d.get::<Name<'_>>(b"BaseFont")).filter(|n| !n.as_ref().is_empty()) { + return Some(String::from_utf8_lossy(name.as_ref()).into_owned()); + } + let cmap = f.get::<Name<'_>>(b"Encoding").map(|n| String::from_utf8_lossy(n.as_ref()).into_owned()); + let stripped = cmap.and_then(|c| base.strip_suffix(c.as_str())?.strip_suffix('-').filter(|b| !b.is_empty()).map(str::to_string)); + Some(stripped.unwrap_or(base)) +} + /// Whether font `f` carries its own glyphs: a font program in its descriptor (a composite font's in /// its descendant's), or a Type 3 font's glyph procedures. A font that doesn't is drawn with a /// stand-in, whose glyphs say nothing about which installed face the document means. diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index dddb0075ec..373437cfa4 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -965,6 +965,12 @@ impl Families { Self(vectorcraft_text::FontDb::global().families().into_iter().map(|f| (norm(&f), f)).collect()) } + /// Just the families `names`, for tests that mustn't depend on the fonts installed. + #[cfg(test)] + pub fn of(names: &[&str]) -> Self { + Self(names.iter().map(|f| (norm(f), f.to_string())).collect()) + } + /// The family and style of a font with base (PostScript) name `name`, or of weight `weight` /// and slant `italic` when the name has no style; whether the family is available. pub fn resolve(&self, name: &str, weight: Option<u32>, italic: bool) -> (String, String, bool) { @@ -977,22 +983,73 @@ impl Families { if let Some((family, style)) = vectorcraft_text::FontDb::global().by_postscript_name(name) { return (family, style, true); } - let (fam, style) = name.split_once(['-', ',']).unwrap_or((name, "")); - let found = [fam, strip_ps(fam)].iter().find_map(|f| self.0.get(&norm(f)).cloned()); - let style = match spaced(strip_ps(style)).as_str() { - "" | "Roman" | "Book" | "Normal" | "Plain" => match (weight.is_some_and(|w| w >= 600) || style.contains("Bold"), italic) { - (true, true) => "Bold Italic".to_string(), - (true, false) => "Bold".to_string(), - (false, true) => "Italic".to_string(), - (false, false) => "Regular".to_string(), + // The longest part of the name before a hyphen (or the comma of `Arial,Bold`, ISO 32000-1 + // §9.6.3) that names an available family, the rest its style: a family can hold hyphens + // (`Noto-Sans-Bold` is Noto Sans, Bold). + let cuts = std::iter::once(name.len()).chain(name.rmatch_indices(['-', ',']).map(|(i, _)| i)); + for cut in cuts { + let (fam, style) = (name.get(..cut).unwrap_or(name), name.get(cut + 1..).unwrap_or("")); + if let Some(f) = [fam, strip_ps(fam)].iter().find_map(|f| self.0.get(&norm(f)).cloned()) { + return (f, style_of(style, weight, italic), true); + } + } + // Not available: split at a comma, or before the first part that is a style + // (`Helvetica-BoldOblique`, `Noto-Sans-Bold`, `AGaramondPro-Bold-SC700`); otherwise the + // whole name is the family (`Noto-Sans`, `MS-PGothic`). + let style_at = |i: usize| name.get(i + 1..).and_then(|rest| rest.split('-').next()).is_some_and(|part| is_style(strip_ps(part))); + let (fam, style) = match name.split_once(',') { + Some(split) => split, + None => match name.match_indices('-').map(|(i, _)| i).find(|&i| style_at(i)) { + Some(i) => (name.get(..i).unwrap_or(name), name.get(i + 1..).unwrap_or("")), + None => (name, ""), }, - s => s.to_string(), }; - match found { - Some(f) => (f, style, true), - None => (spaced(strip_ps(fam)).trim().to_string(), style, false), + let family = spaced(strip_ps(fam)).split(['-', ' ']).filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" "); + (family, style_of(style, weight, italic), false) + } +} + +/// The style a PostScript name's `style` part names, or the one of weight `weight` and slant +/// `italic` when the part names none (`Roman`, `Book`…). +fn style_of(style: &str, weight: Option<u32>, italic: bool) -> String { + match spaced(strip_ps(style)).as_str() { + "" | "Roman" | "Book" | "Normal" | "Plain" => match (weight.is_some_and(|w| w >= 600) || style.contains("Bold"), italic) { + (true, true) => "Bold Italic".to_string(), + (true, false) => "Bold".to_string(), + (false, true) => "Italic".to_string(), + (false, false) => "Regular".to_string(), + }, + s => s.to_string(), + } +} + +/// Whether `s` is made of style words and their usual abbreviations (`BoldOblique`, `SemiboldIt`, +/// `XBold`, `W6`), any case: the last part of a font name that names its style. +fn is_style(s: &str) -> bool { + const WORDS: &str = "bd bk black blk bold book cn cnd compressed con condensed demi ex expanded ext extended extra hairline heavy hv it ita italic light lt md \ + med medium narrow normal obl oblique plain reg regular rg roman sb semi slanted smb super th thin ul ultra w wide x xb xl"; + let s = s.to_ascii_lowercase(); + let s = s.as_bytes(); + // Which starts of `s` the words and digit runs before them reach (a word break, so + // `XBold` is X, Bold even though `xb` also starts it). + let mut reached = vec![false; s.len() + 1]; + if let Some(r) = reached.first_mut() { + *r = !s.is_empty(); + } + for i in 0..s.len() { + if !reached.get(i).copied().unwrap_or(false) { + continue; + } + let rest = s.get(i..).unwrap_or_default(); + let digits = rest.iter().take_while(|b| b.is_ascii_digit()).count(); + let ends = WORDS.split_ascii_whitespace().filter(|w| rest.starts_with(w.as_bytes())).map(|w| i + w.len()); + for end in ends.chain((digits > 0).then_some(i + digits)) { + if let Some(r) = reached.get_mut(end) { + *r = true; + } } } + reached.last().copied().unwrap_or(false) } /// Hebrew or Arabic comes from a PDF in visual order (each glyph where it is drawn): put `t`'s text diff --git a/crates/pdf/src/tests/import_fidelity.rs b/crates/pdf/src/tests/import_fidelity.rs index e08d2e74ac..e60f3e98c5 100644 --- a/crates/pdf/src/tests/import_fidelity.rs +++ b/crates/pdf/src/tests/import_fidelity.rs @@ -406,6 +406,74 @@ fn font_names_resolve_to_available_families() { assert_eq!((family.as_str(), style.as_str(), found), ("Missing Sans Pro", "Bold Italic", false)); } +/// A family can hold hyphens (`Noto-Sans-Bold` is Noto Sans, Bold): the longest part of the name +/// before a hyphen that names an available family is the family, the rest its style. Synthetic +/// family names, so the host's fonts don't matter. +#[test] +fn hyphenated_font_names_resolve_to_the_longest_available_family() { + let f = crate::import_text::Families::of(&["Vctest", "Vctest Sans", "Vctest Sans CJK JP", "Vctest Mono"]); + let ok = |family: &str, style: &str| (family.to_string(), style.to_string(), true); + assert_eq!(f.resolve("HUHQIP+Vctest-Sans", None, false), ok("Vctest Sans", "Regular")); + assert_eq!(f.resolve("Vctest-Sans-Bold", None, false), ok("Vctest Sans", "Bold")); + assert_eq!(f.resolve("Vctest-Sans-Italic", None, false), ok("Vctest Sans", "Italic")); + assert_eq!(f.resolve("Vctest-Sans", Some(700), true), ok("Vctest Sans", "Bold Italic")); + assert_eq!(f.resolve("Vctest-Sans-CJK-JP", None, false), ok("Vctest Sans CJK JP", "Regular")); + // The family is there but not the style: the family, which then draws its closest style. + assert_eq!(f.resolve("Vctest-Mono-Bold", None, false), ok("Vctest Mono", "Bold")); + // A shorter family still takes the name when no longer one is available. + assert_eq!(f.resolve("Vctest-BoldOblique", None, false), ok("Vctest", "Bold Oblique")); + assert_eq!(f.resolve("Vctest,Bold", None, false), ok("Vctest", "Bold")); +} + +/// A missing font's name is split only at a comma (`Arial,Bold`) or before the first part that is +/// a style (`Family-BoldOblique`); otherwise the whole name is the family, so the missing font is +/// reported as `Vcmissing Sans`, not `Vcmissing`. +#[test] +fn a_missing_font_is_split_only_before_a_style() { + let f = crate::import_text::Families::of(&[]); + let missing = |family: &str, style: &str| (family.to_string(), style.to_string(), false); + assert_eq!(f.resolve("Vcmissing-Sans", None, false), missing("Vcmissing Sans", "Regular")); + assert_eq!(f.resolve("Vcmissing-Sans-CJK-JP", None, false), missing("Vcmissing Sans CJK JP", "Regular")); + assert_eq!(f.resolve("Vcmissing-Sans-BoldOblique", None, false), missing("Vcmissing Sans", "Bold Oblique")); + assert_eq!(f.resolve("VcmissingPro-SemiboldIt", None, false), missing("Vcmissing Pro", "Semibold It")); + assert_eq!(f.resolve("VcmissingPSMT-XBold", None, false), missing("Vcmissing", "XBold")); + assert_eq!(f.resolve("Vcmissing-Bold-SC700", None, false), missing("Vcmissing", "Bold-SC 700")); + assert_eq!(f.resolve("Vcmissing-Regular-Regular", None, false), missing("Vcmissing", "Regular-Regular")); + assert_eq!(f.resolve("Vcmissing,Bold", None, false), missing("Vcmissing", "Bold")); + assert_eq!(f.resolve("-Vcmissing", None, false), missing("Vcmissing", "Regular")); + assert_eq!(f.resolve("Vcmissing-Roman", Some(700), false), missing("Vcmissing", "Bold")); + assert_eq!(f.resolve("Vcmissing-", None, true), missing("Vcmissing", "Italic")); + assert_eq!(f.resolve("-Bold", None, false), missing("", "Bold")); +} + +/// A composite (Type 0) font's base name is its CIDFont's name, a hyphen and its CMap's +/// (`SomeFont-Italic-Identity-H`, ISO 32000-1 §9.7.6.1): the font is noted by its CIDFont's name, +/// the PostScript name of the font program (§9.7.4.1), so it is found among the installed fonts. +#[test] +fn a_composite_font_is_noted_by_its_cid_font_name() { + let noted = |font: &str| { + let resources = format!("/Font << /F1 {font} >>"); + let bytes = one_page("BT /F1 24 Tf 10 30 Td <0001> Tj ET", &resources, &[]); + let pdf = craft_pdf::syntax::Pdf::new(bytes).unwrap(); + let mut fonts = crate::import_scan::FontNames::new(); + let mut ocgs = crate::import_scan::Ocgs::read(&pdf); + crate::import_scan::scan_page(&pdf.pages()[0], &mut ocgs, false, &mut fonts, pdf.decode_limits().max_decoded_stream()).unwrap(); + fonts.into_values().map(|f| f.name).collect::<Vec<_>>() + }; + let type0 = |base: &str, enc: &str, descendant: &str| { + let info = "/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >>"; + format!( + "<< /Type /Font /Subtype /Type0 /BaseFont /{base} /Encoding /{enc} /DescendantFonts [<< /Type /Font /Subtype /CIDFontType0 {descendant} {info} >>] >>" + ) + }; + assert_eq!(noted(&type0("MGZJFV+Vctest-Italic-Identity-H", "Identity-H", "/BaseFont /MGZJFV+Vctest-Italic")), ["MGZJFV+Vctest-Italic"]); + // No name in the descendant: the CMap's name comes off the end of the base name. + assert_eq!(noted(&type0("Vctest-Italic-UniJIS-UCS2-H", "UniJIS-UCS2-H", "")), ["Vctest-Italic"]); + // A base name without the CMap's is kept, as is a simple font's. + assert_eq!(noted(&type0("Vctest-Italic", "Identity-H", "")), ["Vctest-Italic"]); + assert_eq!(noted("<< /Type /Font /Subtype /Type1 /BaseFont /Vctest-Identity-H >>"), ["Vctest-Identity-H"]); +} + /// A value of the page's font dictionary that the reader can't parse (a name with a broken `#` /// escape, a number with a stray `-`) is left out when the page's fonts are noted, and the fonts /// after it are noted. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fac518fdde..ce464cc58c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (DXF export: LAYOUT objects write only DXF Reference group codes, for strict readers such as Autodesk Fusion, #3085) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (PDF import: fonts named with hyphens or with a composite font's CMap find the installed family) · **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 @@ -94,7 +94,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | VectorCraft `.vectorcraft` / `.vctemplate` | — | yes | yes | lossless JSON, versioned, save down to v1; property-tested round trips (`format/tests/prop_format.rs`); documents with a lot of path data are written as compact JSON, about a fifth of the size (#1492); a save falls back to a plain fsync and to writing elsewhere and copying in when the folder refuses the usual steps (#1625, #1649, part of #1620; not confirmed on a real network share); the size limits on embedded data also apply to files read from memory (#1783), and saving over a private file on Unix leaves no readable temporary copy beside it (#1778) | | 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, 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+) | 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); 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) | +| 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+) | 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) | | 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 `<title>`, 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`) | @@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | PDF import finds an installed font whose PDF name holds hyphens (`Noto-Sans-Bold` is Noto Sans, Bold: the longest part before a hyphen that names an installed family) and a composite font by its CIDFont's name rather than its base name, which ends in the CMap's (`Name-Identity-H`, [ISO 32000-1 §9.7.6.1](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf)); a missing font is split only before a style part (`Helvetica-BoldOblique`), so it is reported as `MS PGothic`, not `MS`; scores unchanged (estimated) | | 2026-10-31 | minor | DXF export (2000–2018): the LAYOUT objects wrote the paper image origin as a point at group codes 148 and 158, but PLOTSETTINGS defines 148 and 149 and no 158, so strict readers could reject the file (Autodesk Fusion reported "DXF File failed to insert", #3085). They now write 148/149, the shade plot mode, resolution and DPI (76–78, 2004 and later) and flag 1024 on the Model layout; the plot style ACDBPLACEHOLDER no longer carries a subclass marker. Test `r2000_and_later_files_have_the_structure_cad_apps_require` (required sections and table entries, handles and references, LAYOUT group codes); exports pass ezdxf's recover and audit; not tested in Fusion itself; scores unchanged | | 2026-10-31 | minor | `.ai`/EPS editing data: an envelope whose contents are being edited is written the other way round from one that isn't: its contents, a group on `%_` lines, then its dictionary, which holds the mesh that shapes them. Those contents were read as art, so the undistorted art drew over the distorted art the page draws. The group on `%_` lines before an envelope's dictionary is now left out whatever it holds (text in it stays as hidden text, as the dictionary's own does), as the envelope's mesh already was (#2405). Test `import::ai::tests::an_envelope_s_contents_being_edited_are_not_read_as_art`; checked locally against a user's file: it opens from its layers, 0.0% from its page (it was 9.7%, over the 5% limit); scores unchanged | | 2026-10-31 | minor | `.ai`/EPS editing data: a transparency (`Xy`) set by art inside a dictionary, as an area text's frame (its `/ConfiningPath`) or a mask's art sets one, stays the current one after the dictionary: the writer goes on from it, so the objects after such a text have no `Xy` of their own. It was dropped when the dictionary closed, and a user's file drew its logo, calligraphy, footer and type after an area text with the Lighten of the glow before it, so they faded into the light background. Test `import::ai::tests::a_transparency_set_in_a_dictionary_s_art_stays_after_it`; checked locally against that file: 8.1% → 1.7% from its page, so it opens from its layers (5% is the limit); scores unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index 16da466298..eb8d42b54e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G13: Direct Selection snaps a dragged line end square to straight paths and grazing curves; G13: gradient presets done; G1: Average & Join with a corner or smooth choice, Cmd+Alt+Shift+J, done; estimate unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G4: PDF import finds installed fonts whose PDF names hold hyphens or a composite font's CMap; estimate unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -330,6 +330,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Improved:** mixed paragraph alignments leave the existing Paragraph/Properties alignment buttons unselected; choosing an alignment still uses the range-aware command. Headless regressions, full integration CI and visual review pass. Other mixed paragraph fields remain; estimate unchanged. - **Improved:** Paragraph panel left/right/first-line indents and space before/after show blank when paragraphs in scope differ; uniform values still display and edits apply through `text.setFormat`. Headless regression in `panels/paragraph.rs`; estimate unchanged. - **Improved (#1258):** full font names (name ID 4) resolve the face instead of falling back, and Missing Fonts searches recognize the same aliases. Synthetic regressions cover loaded/cataloged fonts, localized names, real-family precedence and variable default faces. Windows font-service activation still needs reporter QA; estimate unchanged. +- **Improved:** PDF import finds an installed font whose PDF name holds hyphens (`Noto-Sans-Bold`, `Noto-Sans-CJK-JP`) or a composite font's CMap (`SomeFont-Italic-Identity-H`; ISO 32000-1 §9.7.6.1), and names a missing one by the whole name unless a style part follows (`MS-PGothic` is MS PGothic). Synthetic regressions in `crates/pdf/src/tests/import_fidelity.rs`; estimate unchanged. - **Done:** Paragraph panel menu › Roman Hanging Punctuation (#2756), per paragraph; estimate unchanged. - **Improved:** hanging punctuation (Roman Hanging Punctuation and Optical Margin Alignment) keeps its published amounts when justification scales glyphs, and Standard burasagari counts a hung line start as room; paragraph-level tests with both composers; estimate unchanged. - **Done:** Paragraph panel menu › Justification… (#2755): word and letter spacing, glyph scaling (Minimum, Desired, @@ -675,6 +676,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | G4: PDF import finds an installed font whose PDF name holds hyphens (`Noto-Sans-Bold` is Noto Sans, Bold: the longest part before a hyphen that names an installed family) and a composite font by its CIDFont's name rather than its base name, which ends in the CMap's (`Name-Identity-H`, [ISO 32000-1 §9.7.6.1](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf)); a missing font is split only before a style part (`Helvetica-BoldOblique`), so it is reported as `MS PGothic`, not `MS`. Estimate unchanged. | | 2026-10-31 | minor | G13: Direct Selection snaps the end of an existing straight line, dragged by its end anchor, square to straight paths and grazing curves (Smart Guides › Geometric Guides: Snap to Perpendicular and Snap to Tangent), the case the public Snap to Perpendicular and Snap to Tangent pages add to drawing with the Line Segment and Pen tools (#2530). Scores and estimates unchanged. | | 2026-10-31 | minor | G8: a gradient mesh read from a file is budgeted: at most about a million quads per tessellation, and a mesh with more patches than that is malformed (#3592); estimate unchanged. | | 2026-10-30 | minor | 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. | diff --git a/docs/type-parity.md b/docs/type-parity.md index dbf195b8a8..4de41b37c3 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Highlight Alternate Glyphs: an optional feature (such as `salt`) that changes a lam-alef ligature is highlighted again, only the required ligature staying clear, and a lam-alef with any combining mark (Arabic Extended-A/B/C included) counts as the ligature (#394); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (PDF import finds installed fonts whose PDF names hold hyphens or a composite font's CMap; scores unchanged) · **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 @@ -71,6 +71,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | PDF import finds an installed font whose PDF name holds hyphens (`Noto-Sans-Bold` is Noto Sans, Bold: the longest part before a hyphen that names an installed family) and a composite font by its CIDFont's name rather than its base name, which ends in the CMap's (`Name-Identity-H`, [ISO 32000-1 §9.7.6.1](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf)); a missing font is split only before a style part (`Helvetica-BoldOblique`), so it is reported as `MS PGothic`, not `MS`; scores unchanged | | 2026-10-31 | minor | Highlight Alternate Glyphs: an optional feature (such as `salt`) that changes a lam-alef ligature is highlighted again, only the required ligature staying clear, and a lam-alef with any combining mark (Arabic Extended-A/B/C included) counts as the ligature (#394); scores unchanged | | 2026-10-30 | minor | Highlight Alternate Glyphs leaves the required lam-alef ligature unhighlighted also in older Arabic fonts (such as legacy Arial) that put it in `liga` rather than `rlig` (#394); scores unchanged | | 2026-10-30 | minor | Character panel menu › Show Snap to Glyph Options shows the Snap to Glyph section (Baseline, x-height, Glyph Bounds, Proximity Guides, Angular Guides, Anchor Point; `view.snapToGlyph.options`) (#4232); scores unchanged | From bd0601c7809c5d81c7189741bba2d2747abbd8be Mon Sep 17 00:00:00 2001 From: marshiyar <57055687+marshiyar@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:21:04 -0700 Subject: [PATCH 63/98] EPS import: big operators are charged for the bytes and objects they touch (#3584) putinterval, copy of a string, roll, counttomark, clear to mark and cvn worked on up to 4 MiB or 100,000 objects for the one operation each is charged, so a small file looped over them could keep the importer busy for hours within the operation budget. Each now spends one operation per 256 bytes or objects. Fixes #3584 --- crates/eps/src/import/interp.rs | 21 +++++++++++++++++++-- crates/eps/src/import/tests.rs | 15 +++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 5e8d12661b..68053f29bd 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -335,6 +335,12 @@ impl<'a> Interp<'a> { /// Count `ops` operations' worth of work against [`MAX_OPS`]: what an operator that costs /// far more than one operation (laying out type) does in a loop has to end too. + /// Charge for work on `n` bytes or objects in one operator: one operation per 256, as `search` + /// charges per 64 bytes, so a loop of big copies runs out of the budget. + fn spend_bytes(&mut self, n: usize) -> Res { + self.spend((n / 256) as u64) + } + pub fn spend(&mut self, ops: u64) -> Res { self.ops = self.ops.saturating_add(ops); if self.ops > MAX_OPS { @@ -661,6 +667,7 @@ impl<'a> Interp<'a> { let n = self.pop_count()?; let at = self.stack.len().checked_sub(n).ok_or(PsError::Ps("rangecheck", String::new()))?; if n > 0 { + self.spend_bytes(n)?; let k = j.rem_euclid(n as i64) as usize; if let Some(s) = self.stack.get_mut(at..) { s.rotate_right(k); @@ -687,6 +694,7 @@ impl<'a> Interp<'a> { self.pop_to_mark()?; } CountToMark => { + self.spend_bytes(self.stack.len())?; let n = self.stack.iter().rev().position(|o| matches!(o, Obj::Mark)).ok_or(PsError::Ps("unmatchedmark", String::new()))?; self.push(Obj::Int(n as i64))?; } @@ -860,6 +868,7 @@ impl<'a> Interp<'a> { } Cvn => { let s = self.pop_str()?; + self.spend_bytes(s.len())?; // The name is a copy of the string (a bigger one when bytes are replaced). self.alloc(s.len().saturating_mul(3))?; let n: Rc<str> = Rc::from(String::from_utf8_lossy(&s.borrow()).as_ref()); @@ -966,8 +975,14 @@ impl<'a> Interp<'a> { let src = self.pop()?; let at = self.pop_count()?; let written = match (self.pop()?, src) { - (Obj::Array { items, .. }, Obj::Array { items: from, .. }) => items.write(at, &from.to_vec()), - (Obj::Str(s), Obj::Str(from)) => s.write(at, &from.to_vec()), + (Obj::Array { items, .. }, Obj::Array { items: from, .. }) => { + self.spend_bytes(from.len())?; + items.write(at, &from.to_vec()) + } + (Obj::Str(s), Obj::Str(from)) => { + self.spend_bytes(from.len())?; + s.write(at, &from.to_vec()) + } _ => return ps_err("typecheck", ""), }; if !written { @@ -1171,6 +1186,7 @@ impl<'a> Interp<'a> { /// The objects above the topmost mark, which is removed. fn pop_to_mark(&mut self) -> Res<Vec<Obj>> { + self.spend_bytes(self.stack.len())?; let at = self.stack.iter().rposition(|o| matches!(o, Obj::Mark)).ok_or(PsError::Ps("unmatchedmark", String::new()))?; let items = self.stack.split_off(at + 1); self.stack.pop(); @@ -1202,6 +1218,7 @@ impl<'a> Interp<'a> { } Obj::Str(s) => { let from = self.pop_str()?.to_vec(); + self.spend_bytes(from.len())?; if !s.write(0, &from) { return ps_err("rangecheck", "copy"); } diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index d722078b77..aa97f32cb5 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -1307,6 +1307,21 @@ fn search_in_a_loop_runs_out_of_the_budget() { runs_out_of_budget(&format!("{make} {{ h n search {{ pop pop pop }} {{ pop }} ifelse }} loop")); } +/// #3584: operators that touch up to 4 MiB or 100,000 objects are charged for it, so a loop of them +/// runs out of the operation budget instead of keeping the importer busy for hours. +#[test] +fn big_operators_in_a_loop_run_out_of_the_budget() { + let strings = "/a 4194304 string def /b 4194304 string def"; + for body in [ + format!("{strings} {{ a 0 b putinterval }} loop"), + format!("{strings} {{ b a copy pop }} loop"), + "99990 { 0 } repeat { 99990 1 roll } loop".to_string(), + "mark 99990 { 0 } repeat { counttomark pop } loop".to_string(), + ] { + runs_out_of_budget(&body); + } +} + /// #3568: an image dictionary with more data sources than colour components is a `rangecheck`. #[test] fn an_image_with_more_data_sources_than_components_is_refused() { From feb12cf3126f26fd5106a89ed8d21c4238e91681 Mon Sep 17 00:00:00 2001 From: marshiyar <57055687+marshiyar@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:43:20 -0700 Subject: [PATCH 64/98] Swatch libraries: a device name with an extension gets the - before its first dot (#3602) CON.txt became CON.txt-.json, whose first part is still CON. It is now CON-.txt.json. --- crates/engine/src/cmd/swatchlib.rs | 7 +++++-- crates/engine/src/tests/swatchlib.rs | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index d3652aa4e1..50033eff77 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -332,14 +332,17 @@ fn library_tab_name(file: &str, lib_name: &str) -> String { /// The file name of library `name` with extension `ext`: characters file systems reject become /// `-`, and dots and spaces at either end are dropped. A name with nothing left is `Library`, and -/// one that is a Windows device name (`CON`, `NUL`, `COM1`…) gets a `-` after it. +/// one that is a Windows device name (`CON`, `NUL`, `COM1`…, also with an extension) gets a `-` +/// after its first part (`CON-.json`, `CON-.txt.json`). pub fn library_file_name(name: &str, ext: &str) -> String { let base: String = name.chars().map(|c| if "/\\:*?\"<>|".contains(c) || c.is_control() { '-' } else { c }).collect(); let base = base.trim_matches(['.', ' ']); if base.is_empty() { format!("Library.{ext}") } else if crate::file_access::is_device_name(base) { - format!("{base}-.{ext}") + // The part before the first dot is what makes it a device, so the `-` goes there. + let (stem, rest) = base.split_once('.').map_or((base, String::new()), |(stem, rest)| (stem, format!(".{rest}"))); + format!("{stem}-{rest}.{ext}") } else { format!("{base}.{ext}") } diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index d6c9f84520..7337d6b744 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -642,11 +642,14 @@ fn gradient_libraries_are_listed_and_gradient_swatches_rename_and_take_new_gradi #[test] fn a_library_file_name_is_never_a_windows_device_name() { use crate::cmd::swatchlib::library_file_name; - for (title, ext) in [("CON", "json"), ("nul", "json"), ("Com1", "acb"), ("LPT9", "ase"), ("aux.", "json")] { + for (title, ext) in + [("CON", "json"), ("nul", "json"), ("Com1", "acb"), ("LPT9", "ase"), ("aux.", "json"), ("CON.txt", "json"), ("nul.tar.gz", "acb")] + { let file = library_file_name(title, ext); assert!(!crate::file_access::is_device_name(&file), "{title}: {file}"); assert!( - file.ends_with(&format!(".{ext}")) && file.to_ascii_lowercase().contains(&title.trim_end_matches('.').to_ascii_lowercase()), + file.ends_with(&format!(".{ext}")) + && file.to_ascii_lowercase().starts_with(&title.split('.').next().unwrap_or_default().to_ascii_lowercase()), "{title}: {file}" ); } From 811ba8727bd65b65d0f933f846bfe6ec07f9a581 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:27:50 +0430 Subject: [PATCH 65/98] Fix #3604: automation file access refuses an NTFS alternate data stream name on Windows With write root ...\out, a save to ...\out\x.svg:evil was accepted: it stays inside the root, but writes a hidden stream no folder listing shows. A ':' in any name after the drive is now refused with the device names. The check is a plain name test, so it is covered on every platform; the check-then-open race (item 1 of the report) is a documented limitation and is not touched. --- crates/engine/src/file_access.rs | 43 ++++++++++++++++++++++++++++++-- docs/control-protocol.md | 5 ++-- docs/mcp.md | 3 ++- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 56db72a222..d110149dac 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -213,8 +213,10 @@ fn resolve(path: &str) -> Result<PathBuf, String> { return Err("automation path rejected: path is empty".into()); } // Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows. - if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) { - return Err(format!("automation path rejected: reserved device names are not allowed: {path}")); + if cfg!(windows) + && let Some(why) = windows_name_refusal(path) + { + return Err(format!("automation path rejected: {why}: {path}")); } let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?; // The names below the nearest existing folder, last first. @@ -280,6 +282,18 @@ pub(crate) fn is_device_name(component: &str) -> bool { matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT") } +/// Why Windows would not take `path` as an ordinary file name, for the first of its components +/// that is a device name, or holds a `:` (a drive letter's own colon belongs to the prefix, not to +/// a name, so only a stream such as `x.svg:evil` has one). (Judged by name alone, so it can be +/// tested on any platform; `resolve` applies it on Windows only.) +fn windows_name_refusal(path: &str) -> Option<&'static str> { + Path::new(path).components().find_map(|c| match c { + Component::Normal(n) if is_device_name(&n.to_string_lossy()) => Some("reserved device names are not allowed"), + Component::Normal(n) if n.to_string_lossy().contains(':') => Some("alternate data streams are not allowed"), + _ => None, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -497,4 +511,29 @@ mod tests { assert!(!is_device_name(f), "{f}"); } } + + /// #3604: on Windows `x.svg:evil` writes a hidden NTFS alternate data stream of `x.svg`, which + /// stays inside the root yet shows in no folder listing, so a `:` in any component after the + /// drive is refused (a drive letter's own colon is not a component). + #[test] + fn alternate_data_streams_are_refused() { + for p in ["x.svg:evil", "out/x.svg:evil", "out/x.svg:evil:$DATA", "out/sub:evil/x.svg", "out/x.svg::$DATA", "out/:evil"] { + assert_eq!(windows_name_refusal(p), Some("alternate data streams are not allowed"), "{p}"); + } + for p in ["x.svg", "out/x.svg", "out/a b.svg", "out/x.svg.evil", "out/NUL"] { + assert_ne!(windows_name_refusal(p), Some("alternate data streams are not allowed"), "{p}"); + } + assert_eq!(windows_name_refusal("out/NUL"), Some("reserved device names are not allowed")); + assert_eq!(windows_name_refusal("out/x.svg"), None); + } + + #[cfg(windows)] + #[test] + fn windows_refuses_a_stream_inside_a_write_root() { + let (base, roots) = folders("streams"); + let e = roots.check(&s(base.join("out").join("x.svg:evil")), Access::Write).unwrap_err(); + assert!(e.contains("alternate data streams"), "{e}"); + assert_eq!(roots.check(&s(base.join("out/x.svg")), Access::Write), Ok(())); + let _ = std::fs::remove_dir_all(base); + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2f2a86d26f..1e718c9da5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -1,6 +1,6 @@ # Control protocol -> **Last updated:** 2026-10-25 · **Change:** Object → Expand… dimmed while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089); previously: brush library panels (`window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary`); previously: Transform Each opens on the values it was last confirmed with; its Copy button confirms with `copy: true` (#1732); previously: panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726). +> **Last updated:** 2026-10-31 · **Change:** Confining file access refuses a `:` in a name after the drive on Windows, an NTFS alternate data stream such as `x.svg:evil` (#3604); previously: Object → Expand… dimmed while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089); previously: brush library panels (`window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary`); previously: Transform Each opens on the values it was last confirmed with; its Copy button confirms with `copy: true` (#1732); previously: panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726). `vectorcraft --control <port>` listens on `127.0.0.1:<port>` (loopback only). The port must be a number from 1 to 65535 (`--control=<port>` works too), given once; otherwise the app prints an error and exits @@ -68,7 +68,7 @@ writes (save, export, package, captures, library saves…) inside a write root; ordinary error reply. Paths are made absolute against the app's working directory, links are followed (for a file that doesn't exist yet, its folder's), and the comparison is by whole folder names (`/work2` is not inside `/work`), ignoring case on Windows; `..` below a folder that doesn't exist, a link that leads -nowhere and Windows device names are refused. A root left out grants none of its access. The roots must be +nowhere, Windows device names and, on Windows, a `:` in any name after the drive (an NTFS alternate data stream such as `x.svg:evil`) are refused. A root left out grants none of its access. The roots must be existing folders, or the app doesn't start; without `--control` they are ignored. See `docs/mcp.md` › Confining file access for the details. @@ -955,6 +955,7 @@ hidden tab (whose frames the browser pauses) answers when it is shown again. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Confining file access: on Windows a `:` in a name after the drive (an NTFS alternate data stream) is refused with the device names (#3604). | | 2026-10-25 | minor | Panel docking on craft-ui's layout: `ui.docking`, the floating and dock parameters, native floating windows. | | 2026-10-24 | minor | Documented the brush library panel commands (`window.brushLibrary`, `.other`, `ui.saveBrushLibrary`, `window.userBrushLibrary1`…`10`). | | 2026-10-24 | minor | Documented `ui.screenshot`'s 64 MiB inline-PNG reply cap: with `path` the file is still written and `pngBase64` is omitted when over the cap; a path-less `data: true` request fails before encoding. | diff --git a/docs/mcp.md b/docs/mcp.md index 81681d6c2f..90ce4f6c3a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -125,7 +125,8 @@ the suite) keep the agent to the folders you give it: doesn't exist is refused, as is a link that leads nowhere (writing through it would create its target). The result is compared with the root by whole folder names: `/work2/x` is not inside `/work`. On Windows case is ignored, UNC paths (`\\server\share\…`) are resolved like drive-letter ones, and - device names (`CON`, `NUL`, `COM1`…) are refused. + device names (`CON`, `NUL`, `COM1`…) and a `:` in a name after the drive (an NTFS alternate data stream + such as `x.svg:evil`, which hides data in a stream no folder listing shows) are refused. - With `--connect`, the server makes the paths of `open_file`, `save_file`, `export` (`path` or `options.path`) and `screenshot {path}` absolute against its own working directory before it sends them to the app. Paths inside `run_command` and `command_batch` params go to the app as given, and the From f9a523b55e01e1706343729275a6f6e0d3d61bf0 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:46:52 +0430 Subject: [PATCH 66/98] Docs: Snapping is no longer a main gap in the parity table (#2530) The tangent and perpendicular case is done for every tool the public pages name; its evidence stays in the revision row and in gaps.md and ui-parity.md. --- docs/target-app-parity.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0f2883ae6b..58e42441c6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -141,10 +141,10 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), guides lost (#779), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC/JPEG 2000 import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; 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. | | View & navigation | 3 | 70% | multiple windows and Arrange | 9–14 | estimated; 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 Arrange | 9–14 | estimated | -| Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent snapping to curves and perpendicular snapping to straight segments for lines drawn with the Pen, Curvature and Line Segment tools and for an existing line's end dragged with Direct Selection (Preferences › Smart Guides › Geometric Guides and Control bar › Snapping, #2530; the public pages name no other tool), "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); new ruler-guide Alt orientation implemented; video rulers (View › Rulers › Show Video Rulers, #2849) done, score unchanged | -| Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent snapping to curves and perpendicular snapping to straight segments for lines drawn with the Pen, Curvature and Line Segment tools and for an existing line's end dragged with Direct Selection (Preferences › Smart Guides › Geometric Guides and Control bar › Snapping, #2530; the public pages name no other tool), "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), global/artboard ruler mode switch, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented; video rulers (View › Rulers › Show Video Rulers, #2849) done, score unchanged | -| Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent snapping to curves and perpendicular snapping to straight segments for lines drawn with the Pen, Curvature and Line Segment tools and for an existing line's end dragged with Direct Selection (Preferences › Smart Guides › Geometric Guides and Control bar › Snapping, #2530; the public pages name no other tool), "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented; status-bar X/Y uses the active ruler origin and General units | -| Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent snapping to curves and perpendicular snapping to straight segments for lines drawn with the Pen, Curvature and Line Segment tools and for an existing line's end dragged with Direct Selection (Preferences › Smart Guides › Geometric Guides and Control bar › Snapping, #2530; the public pages name no other tool), "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), global/artboard ruler mode switch and video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented; status-bar X/Y uses the active ruler origin and General units | +| 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); new ruler-guide Alt orientation implemented; video rulers (View › Rulers › Show Video Rulers, #2849) done, score 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), global/artboard ruler mode switch, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented; video rulers (View › Rulers › Show Video Rulers, #2849) done, score 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), video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented; status-bar X/Y uses the active ruler origin and General units | +| 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), global/artboard ruler mode switch and video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented; status-bar X/Y uses the active ruler origin and General units | | 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 | From a5a7c4638ae214e7eb31de6dcbcf0c40632e11a3 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:28:15 +0100 Subject: [PATCH 67/98] Fix closing smart quote read as opening after same-glyph quote Found while testing crates/doc/src/setup.rs:111-113 (Quotes::opens / is_open), used by Quotes::apply at :122-131 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- crates/doc/src/setup.rs | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/crates/doc/src/setup.rs b/crates/doc/src/setup.rs index 875fcf3a0c..ca08eca4a4 100644 --- a/crates/doc/src/setup.rs +++ b/crates/doc/src/setup.rs @@ -107,14 +107,17 @@ impl Quotes { /// Single-quote pairs offered by Document Setup. pub const SINGLE_CHOICES: &[[char; 2]] = &[['‘', '’'], ['‚', '‘'], ['‚', '’'], ['’', '’'], ['‹', '›'], ['›', '‹'], ['『', '』'], ['\'', '\'']]; - /// Is `c` one of these opening quotes? + /// Is `c` one of these opening quotes? A typographic glyph that is also a closing quote does not + /// count; a straight quote has no direction, so it still opens. fn is_open(&self, c: char) -> bool { - c == self.double[0] || c == self.single[0] + (c == self.double[0] || c == self.single[0]) && (c.is_ascii() || (c != self.double[1] && c != self.single[1])) } /// A quote opens after nothing, whitespace, an opening bracket, a dash or another opening quote. - fn opens(&self, prev: Option<char>) -> bool { - prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || self.is_open(p)) + /// `quote_opened` says whether `prev` is a quote this conversion just produced, and if so whether + /// it opened; that settles glyphs which serve as both opening and closing quote. + fn opens(&self, prev: Option<char>, quote_opened: Option<bool>) -> bool { + prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || quote_opened.unwrap_or_else(|| self.is_open(p))) } /// `text` with straight quotes (`"` and `'`) replaced by these quotes: opening or closing by @@ -122,11 +125,16 @@ impl Quotes { /// and is updated to its last character, so text can be converted in pieces. pub fn apply(&self, text: &str, prev: &mut Option<char>) -> String { let mut out = String::with_capacity(text.len()); + let mut quote_opened = None; for c in text.chars() { - let r = match c { - '"' => self.double[usize::from(!self.opens(*prev))], - '\'' => self.single[usize::from(!self.opens(*prev))], - c => c, + quote_opened = match c { + '"' | '\'' => Some(self.opens(*prev, quote_opened)), + _ => None, + }; + let r = match (c, quote_opened) { + ('"', Some(open)) => self.double[usize::from(!open)], + ('\'', Some(open)) => self.single[usize::from(!open)], + (c, _) => c, }; out.push(r); *prev = Some(r); @@ -326,6 +334,18 @@ mod tests { assert_eq!((a.as_str(), b.as_str()), ("“", "“")); } + #[test] + fn closing_quote_equal_to_other_kinds_opening_quote_still_closes() { + let q = Quotes { double: ['”', '”'], single: ['‘', '’'] }; + let mut prev = None; + assert_eq!(q.apply("'\"a\"'", &mut prev), "‘”a”’"); + let q = Quotes { double: ['"', '"'], single: ['‘', '’'] }; + let mut prev = None; + assert_eq!(q.apply("'\"a\"'", &mut prev), "‘\"a\"’"); + let mut prev = None; + assert_eq!(q.apply("\"'a'\"", &mut prev), "\"‘a’\""); + } + #[test] fn bleed_grows_the_artboard() { let s = DocSetup { bleed: [9.0, 9.0, 4.0, 2.0], ..Default::default() }; From d0e1a045c166e9793c51cce6efe0d993e50f4356 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:25:04 +0000 Subject: [PATCH 68/98] Show 0.00 instead of -0.00 in Unit::readout for tiny negatives Found while testing crates/doc/src/units.rs:163-165 (readout) Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- crates/doc/src/tests/units.rs | 7 +++++++ crates/doc/src/units.rs | 5 ++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/tests/units.rs b/crates/doc/src/tests/units.rs index 909a0adf3b..88075a5dc0 100644 --- a/crates/doc/src/tests/units.rs +++ b/crates/doc/src/tests/units.rs @@ -144,3 +144,10 @@ fn mixed_mul_div() { assert_eq!(Unit::Points.parse("10*2/5"), Some(4.0)); assert_eq!(Unit::Points.parse("10*2/5 px"), Some(Unit::Pixels.to_pt(4.0))); } + +#[test] +fn readout_has_no_negative_zero() { + assert_eq!(Unit::Points.readout(-0.001), "0.00 pt"); + assert_eq!(Unit::Points.readout(-0.0), "0.00 pt"); + assert_eq!(Unit::Points.readout(-12.5), "-12.50 pt"); +} diff --git a/crates/doc/src/units.rs b/crates/doc/src/units.rs index 2c1fc86534..6a5edf75ac 100644 --- a/crates/doc/src/units.rs +++ b/crates/doc/src/units.rs @@ -161,7 +161,10 @@ impl Unit { /// A point value as the canvas measurement labels show it: two decimals and the suffix /// (`12.50 mm`). pub fn readout(self, pt: f64) -> String { - format!("{:.2} {}", self.from_pt(pt), self.suffix()) + let s = format!("{:.2}", self.from_pt(pt)); + // A small negative value rounds to `-0.00`; show it as `0.00`. + let s = if s == "-0.00" { "0.00" } else { &s }; + format!("{} {}", s, self.suffix()) } } From 18709b0b63a365658a0d1ed09b88648a84e5748c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:06:34 +0100 Subject: [PATCH 69/98] Reject multiple leading '#' in the colour panel hex parser Found while testing crates/ui-egui/src/panels/color.rs:199-205 (parse_hex) Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/panels/color.rs | 3 ++- crates/ui-egui/src/panels/color/tests.rs | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index f9567e9888..00e32808a5 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -197,7 +197,8 @@ pub fn hex_digits(c: &Color) -> String { /// Parse a hex field (`E67828`, `#e67828`, `fff`). pub fn parse_hex(s: &str) -> Option<Color> { - let s = s.trim().trim_start_matches('#'); + let s = s.trim(); + let s = s.strip_prefix('#').unwrap_or(s); if !(s.len() == 3 || s.len() == 6) || !s.chars().all(|c| c.is_ascii_hexdigit()) { return None; } diff --git a/crates/ui-egui/src/panels/color/tests.rs b/crates/ui-egui/src/panels/color/tests.rs index 548851e6b8..cf02301a62 100644 --- a/crates/ui-egui/src/panels/color/tests.rs +++ b/crates/ui-egui/src/panels/color/tests.rs @@ -673,4 +673,6 @@ fn spectrum_and_hex() { assert_eq!(parse_hex("#fff").unwrap().to_hex(), "#ffffff"); assert!(parse_hex("zz").is_none()); assert!(parse_hex("12345").is_none()); + assert!(parse_hex("##e67828").is_none()); + assert!(parse_hex("###fff").is_none()); } From 3602bd6ea358b4498dd5f91129ca1f0ec28639ae Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:23:05 +0300 Subject: [PATCH 70/98] fix(ai-import): type from the text document takes its character style's attributes Type made from an Illustrator file's text document took each character attribute its run doesn't set from the document's base attributes, not from the character style the run names ([Normal Character Style] or another of the file's). Where the two differ the type came in wrong: a file whose base fill is white and whose Normal Character Style is black came in with its text white, invisible, so the layers differed from the page wherever the page's own type didn't stand in for it. A run now takes its own attributes, then its character style's (and the styles that one is based on, up to 16), then the document's. --- crates/eps/src/import/ate.rs | 70 ++++++++++++++++++++++----- crates/eps/src/import/tests_native.rs | 40 +++++++++++++++ docs/file-format-parity.md | 7 +-- 3 files changed, 102 insertions(+), 15 deletions(-) diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 875a046fc0..a5b22f92d2 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -235,6 +235,11 @@ fn read(bytes: &[u8]) -> Option<Val> { } } +/// Most character styles read of a text document, and most styles a run's style is followed +/// through to the ones they are based on. +const MAX_SHEETS: usize = 4096; +const MAX_SHEETS_BASED_ON: usize = 16; + /// Most frames read of one story, and most path segments of a frame. const MAX_FRAMES: usize = 4096; const MAX_SEGMENTS: usize = 1 << 16; @@ -309,6 +314,9 @@ pub(super) struct Texts { /// The character and paragraph styles a story starts from. defaults: Val, para_defaults: Val, + /// The document's character styles (`[Normal Character Style]` first), each a name, the + /// character style it is based on (key 5, an index into these) and its attributes (key 6). + char_sheets: Vec<Val>, frames: Vec<Val>, } @@ -352,6 +360,16 @@ impl Texts { fonts, defaults: main.get("2")?.clone(), para_defaults: main.get("3").cloned().unwrap_or(Val::Dict(BTreeMap::new())), + char_sheets: doc + .get("0")? + .get("5") + .and_then(|s| s.get("0")) + .map(Val::list) + .unwrap_or_default() + .iter() + .take(MAX_SHEETS) + .map(|s| s.get("0").cloned().unwrap_or(Val::Dict(BTreeMap::new()))) + .collect(), frames: doc.get("0")?.get("8")?.get("0")?.list().to_vec(), }) } @@ -440,8 +458,7 @@ impl Texts { let mut runs = vec![]; for run in s.get("0")?.get("6").map(|p| p.get("0").map(Val::list).unwrap_or_default()).unwrap_or_default() { let n = run.get("1").and_then(Val::num)? as usize; - let props = run.get("0").and_then(|r| r.get("0")).and_then(|r| r.get("6")); - runs.push((n, self.style(props))); + runs.push((n, self.style(run.get("0").and_then(|r| r.get("0"))))); } if runs.is_empty() { runs.push((text.chars().count(), self.style(None))); @@ -465,9 +482,19 @@ impl Texts { } } - /// A character style: its own keys, else the document's. - fn style(&self, s: Option<&Val>) -> Style { - let pick = |k: &str| s.and_then(|s| s.get(k)).or_else(|| self.defaults.get(k)); + /// A run's character style (`run`: its attributes, key 6, and the character style it is based + /// on, key 5): its own keys, else those of its character style and the ones that is based on, + /// else the document's. + fn style(&self, run: Option<&Val>) -> Style { + // The run's attributes, then its character style's, then the styles each is based on. + let mut chain = vec![]; + let mut at = run; + for _ in 0..=MAX_SHEETS_BASED_ON { + let Some(style) = at else { break }; + chain.extend(style.get("6")); + at = style.get("5").and_then(Val::num).filter(|i| i.is_finite() && *i >= 0.0).and_then(|i| self.char_sheets.get(i as usize)); + } + let pick = |k: &str| chain.iter().find_map(|s| s.get(k)).or_else(|| self.defaults.get(k)); let num = |k: &str| pick(k).and_then(Val::num); let on = |k: &str| !matches!(pick(k), Some(Val::Bool(false))); let font = num("0").and_then(|i| self.fonts.get(i as usize)).filter(|f| !f.is_empty()).cloned(); @@ -899,8 +926,14 @@ mod tests { #[test] fn right_to_left_paragraphs_swap_left_and_right_alignment() { - let texts = - Texts { stories: vec![], fonts: vec![], defaults: Val::Dict(BTreeMap::new()), para_defaults: read(b"/33 1").unwrap(), frames: vec![] }; + let texts = Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: read(b"/33 1").unwrap(), + char_sheets: vec![], + frames: vec![], + }; let justify = |own: &[u8]| para_style(&texts.para(Some(&read(own).unwrap())), 1.0).justify; // Right to left from the document's defaults, or the paragraph's own. assert_eq!(justify(b"/0 0"), Justify::Right); @@ -915,9 +948,15 @@ mod tests { /// dimension's is) lies in a tall box, not a wide one. #[test] fn point_type_lies_along_its_baseline() { - let para = - Texts { stories: vec![], fonts: vec![], defaults: Val::Dict(BTreeMap::new()), para_defaults: Val::Dict(BTreeMap::new()), frames: vec![] } - .para(None); + let para = Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![], + frames: vec![], + } + .para(None); let mut s = story(" 80 mm ", &[0.0], ¶); let size = |s: &Story| s.region((0.0, 0.0), Affine::IDENTITY).map(|r| (r.width(), r.height())).unwrap(); let (w, h) = size(&s); @@ -933,8 +972,14 @@ mod tests { /// that doesn't say, goes by its letters. #[test] fn right_to_left_paragraphs_set_their_direction() { - let texts = - Texts { stories: vec![], fonts: vec![], defaults: Val::Dict(BTreeMap::new()), para_defaults: read(b"/33 1").unwrap(), frames: vec![] }; + let texts = Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: read(b"/33 1").unwrap(), + char_sheets: vec![], + frames: vec![], + }; let direction = |own: &[u8]| para_style(&texts.para(Some(&read(own).unwrap())), 1.0).direction; assert_eq!(direction(b"/0 2"), Some(ParaDirection::RightToLeft), "from the document's defaults"); assert_eq!(direction(b"/0 2 /33 0"), None, "the paragraph's own"); @@ -973,6 +1018,7 @@ mod tests { fonts: vec![], defaults: Val::Dict(BTreeMap::new()), para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![], frames: vec![read(doc.as_bytes()).unwrap()], }; texts.frame(0) diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 0f34869b79..ee4beca057 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -841,6 +841,46 @@ fn type_on_a_hidden_layer_comes_from_the_text_document() { assert!(!r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings); } +/// A run that sets no colour of its own takes its character style's (here `[Normal Character +/// Style]`, black), not the text document's base attributes (here white, which hid the type). +/// A style takes what it doesn't set from the style it is based on, and a run's own setting wins. +#[test] +fn type_from_the_text_document_takes_its_character_styles_attributes() { + let own_fill = "/53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> "; + let run = |style: u8| format!("/5 {style} /6 << /0 0 /1 14.0 >>"); + let doc = text_document(&[ + ("Normal\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), + ("Based\r", 0, (8200.0, 8170.0), (0.0, 0.0), 0.0), + ("Own\r", 0, (8200.0, 8160.0), (0.0, 0.0), 0.0), + ]) + // The first two runs set no colour, the second of the style based on the normal one. + .replacen(own_fill, "", 2) + .replacen(&run(0), "/5 X", 1) + .replacen(&run(0), &run(1), 1) + .replacen("/5 X", &run(0), 1) + // The document's base attributes: grey level 1, white, with auto leading. + .replacen("/2 << /1 12.0 >>", "/2 << /1 12.0 /4 true /53 << /99 /SimplePaint /0 << /0 0 /1 [ 1.0 1.0 ] >> >> >>", 1) + // Its character styles: the normal one black with 18-point leading, and one based on it in red. + .replacen( + "/0 << /1 <<", + "/0 << /5 << /0 [ << /0 << /0 ([Normal Character Style]) /6 << /4 false /5 18.0 \ + /53 << /99 /SimplePaint /0 << /0 1 /1 [ 1.0 0.0 0.0 0.0 ] >> >> >> >> >> \ + << /0 << /0 (Red) /5 0 /6 << /53 << /99 /SimplePaint /0 << /0 1 /1 [ 1.0 1.0 0.0 0.0 ] >> >> >> >> >> ] >> /1 <<", + 1, + ); + let spare = text_object(0) + &text_object(1) + &text_object(2); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &spare)), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["Normal", "Based", "Own"], "{:?}", r.warnings); + let style = |i: usize| (texts[i].runs[0].style.fill.clone(), texts[i].runs[0].style.leading); + let solid = vectorcraft_color::Paint::solid; + use vectorcraft_color::Color; + assert_eq!(style(0), (solid(Color::rgb(0.0, 0.0, 0.0)), Some(18.0)), "the normal style's"); + assert_eq!(style(1), (solid(Color::rgb(1.0, 0.0, 0.0)), Some(18.0)), "its style's colour, the leading of the one it's based on"); + assert_eq!(style(2), (solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), Some(18.0)), "the run's own colour"); +} + #[test] fn type_from_the_text_document_keeps_its_case_setting() { // Key 12 of a character style is its case: 1 Small Caps, 2 All Caps. The characters stay as diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9c866d4223..bab11248d5 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (SVG export and Expand: a gradient mesh patch is cut no finer than its colour changes) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (type from an Illustrator file's text document takes the attributes of its character style) · **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 @@ -84,8 +84,8 @@ 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, 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), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), 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), guides, and every artboard where it is. 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 an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). 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, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's 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 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; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. 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, 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. 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%, 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). 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). 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, 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), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), 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), guides, and every artboard where it is. 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 an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). 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, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's 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 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; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. 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). 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, 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. 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%, 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). 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). 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, 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), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), 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), guides, and every artboard where it is. 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 an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). 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, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's 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 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); the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. 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, 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. 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%, 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). 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). 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, 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), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), 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), guides, and every artboard where it is. 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 an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). 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, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's 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 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); the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. 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). 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, 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. 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%, 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). 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). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format @@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | `.ai`/EPS editing data: type made from the file's text document took each character attribute its run doesn't set from the document's base attributes, not from the character style the run names (`[Normal Character Style]` or another of the file's, and the styles each is based on). Where they differ the type came in wrong: a user's file whose base fill is white and whose Normal Character Style is black came in with its text white, so most of its type was missing wherever the page's own type didn't stand in for it. A run now takes its own attributes, then its character style's, then the document's ([Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html)). Test `import::tests_native::type_from_the_text_document_takes_its_character_styles_attributes`; checked locally against 377 users' files: none newly opens from its PDF part, and with #4791 that file opens from its layers (6.3% → 1.1% from its page); scores unchanged | | 2026-10-31 | minor | SVG export (and Object › Expand, EPS, DXF, EMF and the other outputs that cut a gradient mesh into flat pieces): each patch was cut into 8 × 8 quads whatever its colours, so a patch of one colour became 64 identical pieces. Neighbouring quads are now joined into square blocks wherever the colour changes by no more than about one 8-bit step across the block, so a flat patch is one piece and a patch that shades strongly keeps all 64; a block's outline runs through its quads' corners, and a patch that folds over itself is not joined. A synthetic page of 400 meshes (6 × 6 patches, half flat) exports 468,000 pieces instead of 921,600 (35 MB instead of 66 MB), and resvg draws the two files identically. Tests `mesh_export` (crate `vectorcraft-svg`), `tests::live::mesh_expand_to_flat_pieces`; scores unchanged | | 2026-10-31 | minor | PDF import (and a `.ai`'s PDF part): a transparency group's knockout and isolate flags (`/K`, `/I`; ISO 32000-1, 11.4.6.3) come from the interpreter as it pushes the group. They were read from the file in order and matched to the groups pushed, but a group that adds nothing (full opacity, Normal, no mask, neither flag) is drawn straight onto its parent without being pushed, so every group after one lost its own flags and took another's; and a page drawing form groups more than 10,000 times failed to import (the list of their flags was capped), which it no longer does. A blend's steps written as a knockout group of falling opacity drew as solid dark shapes: a user's `.ai` with 143 such bubbles now draws them as light rings, as poppler does (its bubble area 4.9% → 1.2% of pixels off from poppler's render); 16 other users' PDFs with transparency groups (44 pages) draw the same. Test `tests::import_fidelity::knockout_groups_after_a_group_drawn_straight_keep_their_flag`; scores unchanged | | 2026-10-30 | minor | `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout; blends are written with knockout on) in the state block after its record, as a group takes the one after its end; it was left for the next object, so a blend's steps of falling opacity drew over each other instead of knocking each other out, and every such blend came in too dark (a user's file has 143, its bubbles came in as dark rings). Both a live blend and drawn steps kept as a group take it. Test `import::ai::tests::a_blends_transparency_follows_its_record`; checked locally against that file: it comes 1.3% → 0.6% nearer its page, though a mesh seen through a mesh-shaped opacity mask still keeps it just over the limit. When a blend is the layer's last object and the layer's own state block (`7 () XW`, then `LB`) follows, the state is the layer's and the blend keeps its own, as for meshes (#4748); scores unchanged | From 75d2736a2a4c20e270c3c7b3934639196cbcb9da Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:59:28 +0300 Subject: [PATCH 71/98] fix(eps): area type's first line goes where the file lays it out Area type made from the editing data's text document was laid out with its first baseline the font's ascent below the frame's top. The text document keeps where each of its lines starts, and the file's page draws them there: for some fonts the first line sits well above the font's ascent (Oswald's at about its cap height), so headings came in lower than on the page and the layers were taken to differ from it. When the file's first line is more than half a point from where it is laid out here, the first baseline is now fixed where the file has it (First Baseline: Fixed), measured in the text document's own frame so a copied object's type moves with its frame. # Conflicts: # docs/file-format-parity.md --- crates/eps/src/import/ate.rs | 63 ++++++++++++++++++++++++++++ crates/eps/src/import/native/plan.rs | 3 ++ docs/file-format-parity.md | 3 +- 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index a5b22f92d2..6ad647cad3 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -803,6 +803,35 @@ impl Story { !self.lines.is_empty() && vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), t) } + /// Area type (`node`, the story's first frame as [`Self::node`] makes it with `template` and + /// `to_doc`) whose first line the file lays out higher or lower than it is laid out here, as its + /// page draws it: its first baseline is fixed where the file has it. The font's ascent below the + /// frame's top, the first baseline here, isn't where the app that wrote the file puts it for + /// every font. Whether it was. + pub(super) fn place_first_line(&self, node: &mut Node, template: (f64, f64), to_doc: Affine) -> bool { + // Closer than this (points of the document) and the line is where the file has it. + const SAME: f64 = 0.5; + let NodeKind::Text(t) = &mut node.kind else { return false }; + if !matches!(t.kind, TextKind::Area { .. }) || t.area.vertical_align != vectorcraft_doc::VerticalAlign::Top { + return false; + } + let Some((_, (x, y))) = self.line_starts(template).into_iter().next() else { return false }; + let want = (to_doc * Point::new(x, y)).y; + let db = vectorcraft_text::FontDb::global(); + // A frame too short for the line lays nothing out: as the line needs it (see `fit_first_line`). + let mut probe = TextObject::clone(t); + vectorcraft_text::fit_first_line(db, &mut probe); + let laid = vectorcraft_text::layout(db, &probe); + let Some(first) = laid.lines.first().filter(|l| l.region == 0) else { return false }; + let min = first.first_baseline + (want - first.baseline); + if (want - first.baseline).abs() <= SAME || !min.is_finite() || min <= 0.0 { + return false; + } + t.area.first_baseline = vectorcraft_doc::FirstBaseline::Fixed; + t.area.first_baseline_min = min; + true + } + /// The leading the file's own line positions imply: the distance from one line to the next, less /// the space between paragraphs that is part of it (a paragraph's space after and the next one's /// space before; a layout's lines are where that space has put them, and the text object adds it @@ -1249,4 +1278,38 @@ mod tests { let mut t = area(13.0); assert!(!story("Heading\r", &[], &Para::default()).fit_first_line(&mut t) && height(&t).unwrap().3 == 13.0); } + + #[test] + fn area_type_s_first_line_goes_where_the_file_lays_it_out() { + // GUARDS: area type's first baseline was the font's ascent below the frame's top, where the + // file's own layout (and its page) can have it higher: a heading came in lower than the page's. + let line = |a: (f64, f64), b: (f64, f64)| [a.0, a.1, a.0, a.1, b.0, b.1, b.0, b.1]; + let frame = vec![ + line((10.0, 20.0), (210.0, 20.0)), + line((210.0, 20.0), (210.0, 120.0)), + line((210.0, 120.0), (10.0, 120.0)), + line((10.0, 120.0), (10.0, 20.0)), + ]; + // The canvas is the document here: the art's y up, the document's down. + let (template, to_doc) = ((CANVAS_CENTRE, -CANVAS_CENTRE), Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0])); + let first = |s: &Story| { + let mut node = s.node(NodeId(0), 0, template, to_doc).unwrap(); + let placed = s.place_first_line(&mut node, template, to_doc); + let NodeKind::Text(t) = node.kind else { panic!("area type") }; + let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + (placed, l.lines.first().map(|l| l.baseline), t.area.first_baseline) + }; + let mut s = story("Heading\r", &[], &Para::default()); + s.frames = vec![FrameKind::Area(frame)]; + // Where this lays the line out on its own: the file agrees, and nothing changes. + s.lines = vec![(10.0, 20.0)]; + let (_, Some(own), _) = first(&s) else { panic!("a line") }; + s.lines = vec![(10.0, own)]; + assert_eq!(first(&s), (false, Some(own), vectorcraft_doc::FirstBaseline::Ascent)); + // Higher in the file: there. + s.lines = vec![(10.0, own - 3.0)]; + let (placed, at, fb) = first(&s); + assert!(placed && fb == vectorcraft_doc::FirstBaseline::Fixed, "{fb:?}"); + assert!(at.is_some_and(|y| (y - (own - 3.0)).abs() < 1e-6), "{at:?} for {}", own - 3.0); + } } diff --git a/crates/eps/src/import/native/plan.rs b/crates/eps/src/import/native/plan.rs index cc2772f8ed..4bb3675cd1 100644 --- a/crates/eps/src/import/native/plan.rs +++ b/crates/eps/src/import/native/plan.rs @@ -70,6 +70,9 @@ pub(super) fn plan( let Some(Some(story)) = stories.get(k) else { continue }; let Some(mut node) = story.node(NodeId(0), info.frame, t, to_doc) else { continue }; let mut region = story.region(t, to_doc); + // The first line where the file has it, in the text document's frame (that of a + // copied object is its original's, as its lines are). + story.place_first_line(&mut node, t, to_doc); // Area type and type on a path in the object's own frame: the text document's frame // of a copied object keeps its original's path. frame_text_from_art(info, &mut node, &mut region); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index bab11248d5..44a180f1f2 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (type from an Illustrator file's text document takes the attributes of its character style) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (area type from an Illustrator file's text document starts its first line where the file lays it out) · **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 @@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | `.ai`/EPS editing data: area type made from the file's text document put its first baseline the font's ascent below the frame's top, where the file's own layout (and its page) can have it higher: Oswald's and Urbanist's headings came in a third and an eighth of their size lower than on the page, and each text over them was taken to differ. When the text document's first line is more than half a point from where it is laid out here, the first baseline is fixed (Area Type Options › First Baseline › Fixed) where the file has it; the type's own font metrics aren't changed. Test `import::ate::tests::area_type_s_first_line_goes_where_the_file_lays_it_out`; checked locally against users' files: two election posters come 5.4% → 1.1% and 8.6% → 4.3% from their pages, so they open from their layers, and another 1.7% → 0.3%; scores unchanged | | 2026-10-31 | minor | `.ai`/EPS editing data: type made from the file's text document took each character attribute its run doesn't set from the document's base attributes, not from the character style the run names (`[Normal Character Style]` or another of the file's, and the styles each is based on). Where they differ the type came in wrong: a user's file whose base fill is white and whose Normal Character Style is black came in with its text white, so most of its type was missing wherever the page's own type didn't stand in for it. A run now takes its own attributes, then its character style's, then the document's ([Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html)). Test `import::tests_native::type_from_the_text_document_takes_its_character_styles_attributes`; checked locally against 377 users' files: none newly opens from its PDF part, and with #4791 that file opens from its layers (6.3% → 1.1% from its page); scores unchanged | | 2026-10-31 | minor | SVG export (and Object › Expand, EPS, DXF, EMF and the other outputs that cut a gradient mesh into flat pieces): each patch was cut into 8 × 8 quads whatever its colours, so a patch of one colour became 64 identical pieces. Neighbouring quads are now joined into square blocks wherever the colour changes by no more than about one 8-bit step across the block, so a flat patch is one piece and a patch that shades strongly keeps all 64; a block's outline runs through its quads' corners, and a patch that folds over itself is not joined. A synthetic page of 400 meshes (6 × 6 patches, half flat) exports 468,000 pieces instead of 921,600 (35 MB instead of 66 MB), and resvg draws the two files identically. Tests `mesh_export` (crate `vectorcraft-svg`), `tests::live::mesh_expand_to_flat_pieces`; scores unchanged | | 2026-10-31 | minor | PDF import (and a `.ai`'s PDF part): a transparency group's knockout and isolate flags (`/K`, `/I`; ISO 32000-1, 11.4.6.3) come from the interpreter as it pushes the group. They were read from the file in order and matched to the groups pushed, but a group that adds nothing (full opacity, Normal, no mask, neither flag) is drawn straight onto its parent without being pushed, so every group after one lost its own flags and took another's; and a page drawing form groups more than 10,000 times failed to import (the list of their flags was capped), which it no longer does. A blend's steps written as a knockout group of falling opacity drew as solid dark shapes: a user's `.ai` with 143 such bubbles now draws them as light rings, as poppler does (its bubble area 4.9% → 1.2% of pixels off from poppler's render); 16 other users' PDFs with transparency groups (44 pages) draw the same. Test `tests::import_fidelity::knockout_groups_after_a_group_drawn_straight_keep_their_flag`; scores unchanged | From 5b15b3f56b5c57388d0933cfeb3a13f7907b224c Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:59:18 +0300 Subject: [PATCH 72/98] fix(eps): a transparency set in a dictionary's art stays after it Art inside a dictionary in the editing data, such as an area text's frame (`/ConfiningPath`) or a mask's art, can set a transparency (`Xy`). The writer goes on from it: the objects after an area text whose frame sets one have no `Xy` of their own. The reader dropped that state when the dictionary closed, so those objects took the transparency before the dictionary instead; in a user's file a Lighten glow before an area text made everything after it fade into the light background. The last transparency the art inside a dictionary sets is now the current one after it (and the enclosing dictionary's). Dictionaries outside the layers (the setup's styles) are left as they were. # Conflicts: # docs/file-format-parity.md --- crates/eps/src/import/ai/read/containers.rs | 6 +++++- crates/eps/src/import/ai/read/ops.rs | 22 +++++++++++++++++++++ crates/eps/src/import/ai/read/types.rs | 2 ++ crates/eps/src/import/ai/tests.rs | 15 ++++++++++++++ docs/file-format-parity.md | 3 ++- 5 files changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 8d70933984..e13f97d0cd 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -24,6 +24,7 @@ impl<'a> Reader<'a> { base, clips: Vec::new(), compound_members: false, + state: None, }); self.after_object = false; Ok(()) @@ -260,13 +261,16 @@ impl<'a> Reader<'a> { return Ok(()); } let Some(f) = self.frames.pop() else { return Ok(()) }; - let Frame { kind, children, base, hidden, gs, after, mesh_ended, .. } = f; + let Frame { kind, children, base, hidden, gs, after, mesh_ended, state, .. } = f; let Kind::Obj(mut o) = kind else { return Ok(()) }; obj::close(&mut o, &mut self.stack, base); self.obj_hidden = hidden; // Art made inside a dictionary (type's paths, a mask's art) isn't kept, and doesn't end what // follows the object the dictionary is about: a mesh's state comes after its mask. self.gs = gs; + if let Some(state) = state { + self.keep_state(state); + } self.after_object = after; self.mesh_ended = mesh_ended; if matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) { diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 65cec1b072..9deba88891 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -446,6 +446,28 @@ impl<'a> Reader<'a> { // art moves on, so a path's state before its paint isn't read as its layer's. self.layer_state = in_layer.then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout)); } + self.note_state(); + } + + /// Inside a dictionary in the art (a text's frame, a mask's art), the transparency just set + /// stays after the dictionary: the writer goes on from it, so the objects after an area text + /// whose frame sets one have no `Xy` of their own. + fn note_state(&mut self) { + let gs = &self.gs; + let state = (gs.opacity, gs.blend, gs.isolate, gs.knockout, gs.knockout_shape); + if self.frames.iter().any(|f| matches!(f.kind, Kind::Layer(_))) + && let Some(f) = self.frames.iter_mut().rev().find(|f| matches!(f.kind, Kind::Obj(_))) + { + f.state = Some(state); + } + } + + /// A dictionary's art set transparency `state` (see [`Self::note_state`]): it is the current + /// one now, and the enclosing dictionary's. + pub(super) fn keep_state(&mut self, state: (f32, BlendMode, bool, Knockout, bool)) { + let gs = &mut self.gs; + (gs.opacity, gs.blend, gs.isolate, gs.knockout, gs.knockout_shape) = state; + self.note_state(); } /// `n (style) XW` after an object: `1 (style)` after the object written on `%_` lines, its diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 481a5670ac..6d948ae81f 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -153,6 +153,8 @@ pub(super) struct Frame { pub(super) clips: Vec<usize>, /// A compound member group explicitly declares its subpaths' winding with `D`. pub(super) compound_members: bool, + /// A dictionary in the art: the transparency (`Xy`) its art set last, which stays after it. + pub(super) state: Option<(f32, BlendMode, bool, Knockout, bool)>, } /// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 649d51c385..b5c155d6c6 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -508,6 +508,21 @@ fn a_masked_gradient_mesh_takes_the_transparency_written_after_its_mask() { } } +#[test] +fn a_transparency_set_in_a_dictionary_s_art_stays_after_it() { + // GUARDS: an area text's frame (its `/ConfiningPath` art) sets its own transparency; the objects + // after the text, with no `Xy` of their own, draw with it. Read as the dictionary's alone, they + // drew with the Lighten of the path before the text and vanished on a light background. + let text = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n/Art :\nX=\n0 Ap\n0 1 0 0 0 Xy\n0 w\n100 0 m\n100 10 L\nn\nX+\n\ + ; /ConfiningPath ,\n2 /TextAntialiasing ,\n;\n"; + let body = format!("9 0.5 0 0 0 Xy\n{RED}{}f\n{text}{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0), rect_path(20.0, 0.0, 30.0, 10.0)); + let a = art(&read_ok(&layer(&body)).doc); + let paths: Vec<_> = a.iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).collect(); + assert_eq!(paths.len(), 2, "{a:#?}"); + assert_eq!((paths[0].opacity, paths[0].blend), (0.5, BlendMode::Lighten), "the state before the text"); + assert_eq!((paths[1].opacity, paths[1].blend), (1.0, BlendMode::Normal), "the state the text's art set"); +} + #[test] fn gradients() { let defs = "%AI5_BeginGradient: (G)\n(G) 1 2 Bd\n[\n<00FF>\n0\n0 0 0 0 1 0 0 2 1 6 %_BS\n\ diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 44a180f1f2..ad10039d64 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (area type from an Illustrator file's text document starts its first line where the file lays it out) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (a transparency an Illustrator file sets in a dictionary's art, as an area text's frame does, stays for the objects after it) · **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 @@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | `.ai`/EPS editing data: a transparency (`Xy`) set by art inside a dictionary, as an area text's frame (its `/ConfiningPath`) or a mask's art sets one, stays the current one after the dictionary: the writer goes on from it, so the objects after such a text have no `Xy` of their own. It was dropped when the dictionary closed, and a user's file drew its logo, calligraphy, footer and type after an area text with the Lighten of the glow before it, so they faded into the light background. Test `import::ai::tests::a_transparency_set_in_a_dictionary_s_art_stays_after_it`; checked locally against that file: 8.1% → 1.7% from its page, so it opens from its layers (5% is the limit); scores unchanged | | 2026-10-31 | minor | `.ai`/EPS editing data: area type made from the file's text document put its first baseline the font's ascent below the frame's top, where the file's own layout (and its page) can have it higher: Oswald's and Urbanist's headings came in a third and an eighth of their size lower than on the page, and each text over them was taken to differ. When the text document's first line is more than half a point from where it is laid out here, the first baseline is fixed (Area Type Options › First Baseline › Fixed) where the file has it; the type's own font metrics aren't changed. Test `import::ate::tests::area_type_s_first_line_goes_where_the_file_lays_it_out`; checked locally against users' files: two election posters come 5.4% → 1.1% and 8.6% → 4.3% from their pages, so they open from their layers, and another 1.7% → 0.3%; scores unchanged | | 2026-10-31 | minor | `.ai`/EPS editing data: type made from the file's text document took each character attribute its run doesn't set from the document's base attributes, not from the character style the run names (`[Normal Character Style]` or another of the file's, and the styles each is based on). Where they differ the type came in wrong: a user's file whose base fill is white and whose Normal Character Style is black came in with its text white, so most of its type was missing wherever the page's own type didn't stand in for it. A run now takes its own attributes, then its character style's, then the document's ([Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html)). Test `import::tests_native::type_from_the_text_document_takes_its_character_styles_attributes`; checked locally against 377 users' files: none newly opens from its PDF part, and with #4791 that file opens from its layers (6.3% → 1.1% from its page); scores unchanged | | 2026-10-31 | minor | SVG export (and Object › Expand, EPS, DXF, EMF and the other outputs that cut a gradient mesh into flat pieces): each patch was cut into 8 × 8 quads whatever its colours, so a patch of one colour became 64 identical pieces. Neighbouring quads are now joined into square blocks wherever the colour changes by no more than about one 8-bit step across the block, so a flat patch is one piece and a patch that shades strongly keeps all 64; a block's outline runs through its quads' corners, and a patch that folds over itself is not joined. A synthetic page of 400 meshes (6 × 6 patches, half flat) exports 468,000 pieces instead of 921,600 (35 MB instead of 66 MB), and resvg draws the two files identically. Tests `mesh_export` (crate `vectorcraft-svg`), `tests::live::mesh_expand_to_flat_pieces`; scores unchanged | From 7403003fb1c4a24ed349f5e8a732b8afcd096558 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:59:09 +0300 Subject: [PATCH 73/98] fix(eps): an envelope's contents being edited aren't read as art An envelope whose contents are being edited is written the other way round from one that isn't: its contents, a group on `%_` lines, then its dictionary, which holds the mesh that shapes them. Only a group of meshes was left out before an envelope's dictionary, so those contents were read as art and their undistorted shapes drew over the distorted art that follows on plain lines (and that the page draws). The group on `%_` lines before an envelope's dictionary is now left out whatever it holds; text in it stays as hidden text, as the dictionary's own source text does. # Conflicts: # docs/file-format-parity.md --- crates/eps/src/import/ai/read/containers.rs | 29 ++++++++++++--------- crates/eps/src/import/ai/read/document.rs | 7 ++--- crates/eps/src/import/ai/tests.rs | 27 +++++++++++++++++++ docs/file-format-parity.md | 3 ++- 4 files changed, 49 insertions(+), 17 deletions(-) diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index e13f97d0cd..992a9c5643 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -338,23 +338,26 @@ impl<'a> Reader<'a> { } } - /// Drop the object just read when it is an envelope's own mesh: a group on `%_` lines holding - /// only gradient meshes. Whether it was dropped. - pub(super) fn drop_envelope_mesh(&mut self) -> bool { - fn only_meshes(n: &Node) -> bool { - match &n.kind { - NodeKind::Mesh(_) => true, - NodeKind::Group { children, .. } => !children.is_empty() && children.iter().all(|c| only_meshes(c)), - _ => false, - } - } + /// Drop the object just read when it is an envelope's own: a group on `%_` lines, before the + /// envelope's dictionary. It holds the mesh that shapes the envelope, or, while the envelope's + /// contents are being edited, those contents (the mesh then is in the dictionary); either way the + /// distorted art follows on plain lines. The source text in the contents stays, hidden, as the + /// dictionary's does. Whether it was dropped. + pub(super) fn drop_envelope_art(&mut self) -> bool { let Some(n) = self.last_object() else { return false }; let id = n.id; - if !matches!(n.kind, NodeKind::Group { .. }) || !only_meshes(n) || !self.hidden_ids.contains(&id) { + if !matches!(n.kind, NodeKind::Group { .. }) || !self.hidden_ids.contains(&id) { return false; } - if let Some(f) = self.frames.last_mut() { - f.children.pop(); + let mut slots = vec![]; + if let Some(f) = self.frames.last_mut() + && let Some(group) = f.children.pop() + { + envelope_text_slots(&group, &mut slots); + for mut slot in slots { + slot.visible = false; + f.children.push(slot); + } } self.hidden_ids.remove(&id); self.after_object = false; diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index c1e98d0a25..fb540f1d1f 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -229,9 +229,10 @@ impl<'a> Reader<'a> { self.blend_roles.insert(id, (kind, o.text("ShapeBlendPathID"))); } // An envelope (a distortion, `AIDeformStyle` / `DeformValue`) is written on `%_` lines as - // a group holding the mesh that shapes it; the distorted art the file's PDF part draws - // follows on plain lines. That mesh isn't art, so it goes (#2405). - if (o.get("AIDeformStyle").is_some() || o.get("DeformValue").is_some()) && self.drop_envelope_mesh() { + // a group holding the mesh that shapes it (or its contents, while they are being edited); + // the distorted art the file's PDF part draws follows on plain lines. That group isn't + // art, so it goes (#2405). + if (o.get("AIDeformStyle").is_some() || o.get("DeformValue").is_some()) && self.drop_envelope_art() { return; } if let Some(mask) = o.obj("AI9_Mask").or_else(|| o.obj("Mask")) { diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index b5c155d6c6..cfb6d2b347 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1687,6 +1687,33 @@ fn an_envelope_mesh_is_not_read_as_art() { assert!(art(&doc).iter().any(|n| has_mesh(n)), "{:#?}", art(&doc)); } +/// An envelope whose contents are being edited is written the other way round: its contents, a +/// group on `%_` lines, then its dictionary, which holds the mesh that shapes them. The contents +/// aren't drawn as they are, only the distorted art on the plain lines after them. +#[test] +fn an_envelope_s_contents_being_edited_are_not_read_as_art() { + let corner = |x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| { + format!("%_[0.82 0.83 0.83 1 {x} {y} {} {} 1 {} {} 1] /N X#\n", next.0, next.1, prev.0, prev.1) + }; + let mesh = format!( + "%_/Mesh X!\n%_(DeviceRGB) /CS X#\n%_[0 0] /P X#\n{}{}{}{}%_0 /E X#\n%_/End X!\n%_0 0 Xd\n%_6 () XW\n", + corner(0, 0, (0, 10), (10, 0)), + corner(0, 40, (10, 40), (0, 30)), + corner(40, 40, (40, 30), (30, 40)), + corner(40, 0, (30, 0), (40, 10)), + ); + let contents: String = rect_path(0.0, 0.0, 80.0, 20.0).lines().map(|l| format!("%_{l}\n")).collect(); + let body = format!( + "%_u\n%_u\n%_0 0 1 0 0 0 1 Xa\n{contents}%_f\n%_U\n%_9 () XW\n%_U\n%_/ArtDictionary :\n%_1 /Bool (AIDeformEditModeKey) ,\n\ + %_3 /Int (AIDeformStyle) ,\n%_X=\n{mesh}%_X+\n%_ (AIDeformArtKey) ,\n%_;\n%_\n%_9 () XW\n{RED}{}f\n", + rect_path(0.0, 0.0, 40.0, 40.0) + ); + let doc = read_ok(&layer(&body)).doc; + let a = art(&doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert_eq!(fill(&a[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0))); +} + #[test] fn a_gradient_mesh_reads_as_a_patch() { let corner = |rgb: &str, x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ad10039d64..118fddd9e8 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (a transparency an Illustrator file sets in a dictionary's art, as an area text's frame does, stays for the objects after it) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (an Illustrator envelope whose contents are being edited no longer draws its undistorted contents over its result) · **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 @@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | `.ai`/EPS editing data: an envelope whose contents are being edited is written the other way round from one that isn't: its contents, a group on `%_` lines, then its dictionary, which holds the mesh that shapes them. Those contents were read as art, so the undistorted art drew over the distorted art the page draws. The group on `%_` lines before an envelope's dictionary is now left out whatever it holds (text in it stays as hidden text, as the dictionary's own does), as the envelope's mesh already was (#2405). Test `import::ai::tests::an_envelope_s_contents_being_edited_are_not_read_as_art`; checked locally against a user's file: it opens from its layers, 0.0% from its page (it was 9.7%, over the 5% limit); scores unchanged | | 2026-10-31 | minor | `.ai`/EPS editing data: a transparency (`Xy`) set by art inside a dictionary, as an area text's frame (its `/ConfiningPath`) or a mask's art sets one, stays the current one after the dictionary: the writer goes on from it, so the objects after such a text have no `Xy` of their own. It was dropped when the dictionary closed, and a user's file drew its logo, calligraphy, footer and type after an area text with the Lighten of the glow before it, so they faded into the light background. Test `import::ai::tests::a_transparency_set_in_a_dictionary_s_art_stays_after_it`; checked locally against that file: 8.1% → 1.7% from its page, so it opens from its layers (5% is the limit); scores unchanged | | 2026-10-31 | minor | `.ai`/EPS editing data: area type made from the file's text document put its first baseline the font's ascent below the frame's top, where the file's own layout (and its page) can have it higher: Oswald's and Urbanist's headings came in a third and an eighth of their size lower than on the page, and each text over them was taken to differ. When the text document's first line is more than half a point from where it is laid out here, the first baseline is fixed (Area Type Options › First Baseline › Fixed) where the file has it; the type's own font metrics aren't changed. Test `import::ate::tests::area_type_s_first_line_goes_where_the_file_lays_it_out`; checked locally against users' files: two election posters come 5.4% → 1.1% and 8.6% → 4.3% from their pages, so they open from their layers, and another 1.7% → 0.3%; scores unchanged | | 2026-10-31 | minor | `.ai`/EPS editing data: type made from the file's text document took each character attribute its run doesn't set from the document's base attributes, not from the character style the run names (`[Normal Character Style]` or another of the file's, and the styles each is based on). Where they differ the type came in wrong: a user's file whose base fill is white and whose Normal Character Style is black came in with its text white, so most of its type was missing wherever the page's own type didn't stand in for it. A run now takes its own attributes, then its character style's, then the document's ([Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html)). Test `import::tests_native::type_from_the_text_document_takes_its_character_styles_attributes`; checked locally against 377 users' files: none newly opens from its PDF part, and with #4791 that file opens from its layers (6.3% → 1.1% from its page); scores unchanged | From 4861fb9d8575a65ffe072bcba85d48588a764a53 Mon Sep 17 00:00:00 2001 From: marshiyar <57055687+marshiyar@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:12:50 -0700 Subject: [PATCH 74/98] Engine guard: a recovered panic restores every document's undo history and the active document (#3599) run_guarded put back only the active document's art, selection and interaction. An undo step pushed before the panic stayed (Undo then stepped to a state equal to the current one) and Redo was lost; other documents a command touched kept their half-applied edits; the active document was restored by index. It now snapshots every open document with its history and restores them, and the active document by uid. Fixes #3599 --- crates/engine/src/session/execute.rs | 21 ++++++++------- crates/engine/src/tests.rs | 40 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 24cd7096fc..d3ffe41631 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -199,27 +199,30 @@ impl Session { r } - /// A top-level command: a panic (a bug) becomes [`EngineError::Internal`] and the active - /// document goes back to how it was before the command, instead of crashing the frontend. + /// A top-level command: a panic (a bug) becomes [`EngineError::Internal`] and every open + /// document goes back to how it was before the command (its art, selection, interaction and + /// undo history), and the same document stays active, instead of crashing the frontend. pub(crate) fn run_guarded(&mut self, id: &str, f: impl FnOnce(&mut Self) -> Result<Value>) -> Result<Value> { - let snapshot = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.interaction.clone())); - let active = self.active; + let snapshot: Vec<_> = + self.docs.iter().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.interaction.clone(), d.history.clone())).collect(); + let active = self.active().map(|d| d.uid); let r = match guard::catch_panic(|| self.run_nested(f)) { Ok(r) => r, Err(msg) => { self.depth = 0; self.journal_note.clear(); - if let Some((uid, doc, selection, interaction)) = snapshot - && let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid) - { + for (uid, doc, selection, interaction, history) in snapshot { + // A document the command closed stays closed. + let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid) else { continue }; st.doc = doc; st.selection = selection; st.interaction = interaction; + st.history = history; cmd::globaledit::follow_history(st); st.revision += 1; } - if active.is_some_and(|i| i < self.docs.len()) { - self.active = active; + if let Some(i) = active.and_then(|uid| self.docs.iter().position(|d| d.uid == uid)) { + self.active = Some(i); } Err(EngineError::Internal { cmd: id.to_string(), msg }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c0eb9e253f..c6a2a06bea 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3521,6 +3521,46 @@ fn a_panicking_command_rolls_back_instead_of_crashing() { assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("shape.rectangle")); } +/// A command that recorded an undo step and then panicked leaves Undo and Redo as they were. +#[test] +fn a_panic_after_an_edit_leaves_undo_and_redo_as_they_were() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0); + rect(&mut s, 200.0, 10.0, 50.0, 50.0); + s.execute("edit.undo", &json!({})).unwrap(); + let (undo, redo) = (s.doc().unwrap().history.undo.len(), s.doc().unwrap().history.redo.len()); + assert_eq!((undo, redo), (1, 1)); + let r = s.run_guarded("test.panic", |s| { + s.execute("edit.clear", &json!({"ids": [a.0]}))?; + s.run_nested(|_| panic!("boom")) + }); + assert!(matches!(r, Err(EngineError::Internal { .. }))); + let st = s.doc().unwrap(); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, redo)); + assert!(st.doc.node(a).is_some()); +} + +/// A panic rolls back every document the command touched, and the active one stays the same. +#[test] +fn a_panic_rolls_back_other_documents_and_keeps_the_active_one() { + let mut s = session(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let second = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + let first_uid = s.doc().unwrap().uid; + let second_before = s.docs[1].doc.clone(); + let steps = s.docs[1].history.undo.len(); + let r = s.run_guarded("test.panic", |s| { + s.docs[1].history.undo.clear(); + Arc::make_mut(&mut s.docs[1].doc).remove(second)?; + s.run_nested(|_| panic!("boom")) + }); + assert!(matches!(r, Err(EngineError::Internal { .. }))); + assert!(Arc::ptr_eq(&s.docs[1].doc, &second_before)); + assert_eq!(s.docs[1].history.undo.len(), steps); + assert_eq!(s.doc().unwrap().uid, first_uid); +} + /// New layers and sublayers share one "Layer N" numbering, whatever art the document holds, and /// never repeat a name. #[test] From 22b1c6bc2e03620f57dc7544d40e2b76ef188dfc Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:35:20 +0300 Subject: [PATCH 75/98] Fix #3085: DXF LAYOUT objects write only the group codes the DXF Reference defines The PLOTSETTINGS part of each LAYOUT wrote the paper image origin as a point (148 and 158), but the origin is two values at 148 (x) and 149 (y), and 158 is not a PLOTSETTINGS code. Strict readers such as Autodesk Fusion can reject the file. The ezdxf re-save the reporter found Fusion accepts differs from our export in these objects, not in the geometry. - 148/149 for the paper image origin - shade plot mode, resolution level and DPI (76/77/78) from DXF 2004 - plot flag 1024 (model type) on the Model layout - the plot style ACDBPLACEHOLDER without a subclass marker Test r2000_and_later_files_have_the_structure_cad_apps_require checks the required sections and table entries, handles below $HANDSEED, references that resolve, and LAYOUT group codes. # Conflicts: # docs/file-format-parity.md --- crates/cad/src/tests.rs | 65 ++++++++++++++++++++++++++++++++++++++ crates/cad/src/writer.rs | 18 +++++++++-- docs/file-format-parity.md | 5 +-- 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index f02047492a..c08417b8de 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -125,6 +125,71 @@ fn every_version_declares_its_acadver_and_is_well_formed() { assert_eq!(DxfVersion::from_id("2019"), None); } +/// The structure strict readers (Autodesk Fusion, #3085) need: the required sections and table +/// entries, handles that every reference resolves to, and LAYOUT objects with only the group codes +/// the DXF Reference defines for PLOTSETTINGS and LAYOUT (AutoCAD DXF Reference, OBJECTS section: +/// https://help.autodesk.com/view/OARX/2018/ENU/?guid=GUID-1113675E-AB07-4567-BD47-B7FBE7290D1C). +#[test] +fn r2000_and_later_files_have_the_structure_cad_apps_require() { + use std::collections::HashSet; + // Common object codes, then AcDbPlotSettings, then AcDbLayout (76 is the shade plot mode in + // the first and the UCS type in the second). + let layout_codes: HashSet<i32> = [0, 5, 102, 330, 360, 100] + .into_iter() + .chain([1, 2, 4, 6, 7, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 140, 141, 142, 143, 70, 72, 73, 74, 75, 76, 77, 78, 147, 148, 149, 333]) + .chain([71, 10, 20, 11, 21, 12, 22, 32, 14, 24, 34, 15, 25, 35, 146, 13, 23, 33, 16, 26, 36, 17, 27, 37, 331, 345, 346]) + .collect(); + let d = shapes_doc(); + for v in DxfVersion::ALL.into_iter().filter(|v| *v >= DxfVersion::R2000) { + let out = export(&d, &DxfOptions { version: v, ..opts(&d) }).unwrap(); + let p = pairs(&out.bytes); + for name in ["HEADER", "CLASSES", "TABLES", "BLOCKS", "ENTITIES", "OBJECTS"] { + assert!(p.windows(2).any(|w| w[0] == (0, "SECTION".into()) && w[1] == (2, name.into())), "{v:?}: {name} section"); + } + let tables = section(&p, "TABLES"); + let names = |k: &str| -> Vec<String> { tables.iter().filter(|o| kind(o) == k).filter_map(|o| get(o, 2).map(str::to_string)).collect() }; + for (k, want) in [ + ("LTYPE", "ByBlock"), + ("LTYPE", "ByLayer"), + ("LTYPE", "Continuous"), + ("LAYER", "0"), + ("STYLE", "Standard"), + ("APPID", "ACAD"), + ("DIMSTYLE", "Standard"), + ("BLOCK_RECORD", "*Model_Space"), + ("BLOCK_RECORD", "*Paper_Space"), + ] { + assert!(names(k).iter().any(|n| n == want), "{v:?}: {k} {want}"); + } + // Every object after the header has a handle below $HANDSEED, and every reference to + // another object (owner 330, soft/hard pointers 340/350/390) names one of them. + let body = &p[p.iter().position(|x| *x == (0, "ENDSEC".into())).unwrap()..]; + let handles: HashSet<u32> = body.iter().filter(|x| x.0 == 5 || x.0 == 105).map(|x| u32::from_str_radix(&x.1, 16).unwrap()).collect(); + let seed = u32::from_str_radix(&header(&p, "$HANDSEED").unwrap(), 16).unwrap(); + assert!(handles.iter().all(|h| *h < seed), "{v:?}: handles below the seed"); + for (code, value) in body.iter().filter(|x| matches!(x.0, 330 | 340 | 350 | 390)) { + let h = u32::from_str_radix(value, 16).unwrap(); + assert!(h == 0 || handles.contains(&h), "{v:?}: group {code} points at missing handle {value}"); + } + let objects = section(&p, "OBJECTS"); + let layouts: Vec<&Pairs> = objects.iter().filter(|o| kind(o) == "LAYOUT").collect(); + assert_eq!(layouts.len(), 2, "{v:?}: Model and Layout1"); + for l in &layouts { + let bad: Vec<i32> = l.iter().map(|x| x.0).filter(|c| !layout_codes.contains(c)).collect(); + assert!(bad.is_empty(), "{v:?}: LAYOUT has group codes the DXF Reference doesn't define: {bad:?}"); + // The paper image origin is 148 (x) and 149 (y). + assert!(get(l, 148).is_some() && get(l, 149).is_some(), "{v:?}: paper image origin"); + // Flag 1024 marks the model space layout, and only it. + let name = l.iter().skip_while(|x| x.1 != "AcDbLayout").find(|x| x.0 == 1).map(|x| x.1.as_str()); + let flags: i64 = get(l, 70).unwrap().parse().unwrap(); + assert_eq!(flags & 1024 != 0, name == Some("Model"), "{v:?}: model type flag of {name:?}"); + } + // The plot style placeholder carries only the common object codes. + let holder = objects.iter().find(|o| kind(o) == "ACDBPLACEHOLDER").expect("placeholder"); + assert!(holder.iter().all(|x| matches!(x.0, 0 | 5 | 102 | 330)), "{v:?}: placeholder {holder:?}"); + } +} + #[test] fn r12_viewport_has_no_duplicate_group_codes() { let d = shapes_doc(); diff --git a/crates/cad/src/writer.rs b/crates/cad/src/writer.rs index 665f8576f1..6ba7e5a35d 100644 --- a/crates/cad/src/writer.rs +++ b/crates/cad/src/writer.rs @@ -564,7 +564,10 @@ impl Drawing { c.handle(350, fixed::PLOT_NORMAL); c.raw(100, "AcDbDictionaryWithDefault"); c.handle(340, fixed::PLOT_NORMAL); - c.object("ACDBPLACEHOLDER", fixed::PLOT_NORMAL, fixed::PLOT_STYLES, "AcDbPlaceHolder"); + // A placeholder has no data and no subclass marker of its own. + c.raw(0, "ACDBPLACEHOLDER"); + c.handle(5, fixed::PLOT_NORMAL); + c.handle(330, fixed::PLOT_STYLES); self.layout(c, fixed::MODEL_LAYOUT, "Model", 0, fixed::MODEL_RECORD); self.layout(c, fixed::PAPER_LAYOUT, "Layout1", 1, fixed::PAPER_RECORD); } @@ -618,14 +621,23 @@ impl Drawing { c.num(141, 0.0); c.num(142, 1.0); c.num(143, 1.0); - c.int(70, 688); + // Plot flags; 1024 marks the model space layout. + c.int(70, if record == fixed::MODEL_RECORD { 688 | 1024 } else { 688 }); c.int(72, 1); c.int(73, 0); c.int(74, 5); c.raw(7, ""); c.int(75, 16); + if self.version.true_color() { + // Shade plot mode (as displayed), resolution level (normal) and custom DPI. + c.int(76, 0); + c.int(77, 2); + c.int(78, 300); + } c.num(147, 1.0); - c.xy(148, 0.0, 0.0); + // The paper image origin is 148 (x) and 149 (y), not a point at 148 and 158. + c.num(148, 0.0); + c.num(149, 0.0); c.raw(100, "AcDbLayout"); c.raw(1, name); c.int(70, 1); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 118fddd9e8..fac518fdde 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (an Illustrator envelope whose contents are being edited no longer draws its undistorted contents over its result) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (DXF export: LAYOUT objects write only DXF Reference group codes, for strict readers such as Autodesk Fusion, #3085) · **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 @@ -98,7 +98,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | 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) | | 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 `<title>`, 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). CNC preset and VCarve QA remain open | +| 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 | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | | PSD | open, place, export | yes (merged image, every mode and depth) | yes (layered, max editability) | placing as layers (Photoshop Import Options) missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | @@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | DXF export (2000–2018): the LAYOUT objects wrote the paper image origin as a point at group codes 148 and 158, but PLOTSETTINGS defines 148 and 149 and no 158, so strict readers could reject the file (Autodesk Fusion reported "DXF File failed to insert", #3085). They now write 148/149, the shade plot mode, resolution and DPI (76–78, 2004 and later) and flag 1024 on the Model layout; the plot style ACDBPLACEHOLDER no longer carries a subclass marker. Test `r2000_and_later_files_have_the_structure_cad_apps_require` (required sections and table entries, handles and references, LAYOUT group codes); exports pass ezdxf's recover and audit; not tested in Fusion itself; scores unchanged | | 2026-10-31 | minor | `.ai`/EPS editing data: an envelope whose contents are being edited is written the other way round from one that isn't: its contents, a group on `%_` lines, then its dictionary, which holds the mesh that shapes them. Those contents were read as art, so the undistorted art drew over the distorted art the page draws. The group on `%_` lines before an envelope's dictionary is now left out whatever it holds (text in it stays as hidden text, as the dictionary's own does), as the envelope's mesh already was (#2405). Test `import::ai::tests::an_envelope_s_contents_being_edited_are_not_read_as_art`; checked locally against a user's file: it opens from its layers, 0.0% from its page (it was 9.7%, over the 5% limit); scores unchanged | | 2026-10-31 | minor | `.ai`/EPS editing data: a transparency (`Xy`) set by art inside a dictionary, as an area text's frame (its `/ConfiningPath`) or a mask's art sets one, stays the current one after the dictionary: the writer goes on from it, so the objects after such a text have no `Xy` of their own. It was dropped when the dictionary closed, and a user's file drew its logo, calligraphy, footer and type after an area text with the Lighten of the glow before it, so they faded into the light background. Test `import::ai::tests::a_transparency_set_in_a_dictionary_s_art_stays_after_it`; checked locally against that file: 8.1% → 1.7% from its page, so it opens from its layers (5% is the limit); scores unchanged | | 2026-10-31 | minor | `.ai`/EPS editing data: area type made from the file's text document put its first baseline the font's ascent below the frame's top, where the file's own layout (and its page) can have it higher: Oswald's and Urbanist's headings came in a third and an eighth of their size lower than on the page, and each text over them was taken to differ. When the text document's first line is more than half a point from where it is laid out here, the first baseline is fixed (Area Type Options › First Baseline › Fixed) where the file has it; the type's own font metrics aren't changed. Test `import::ate::tests::area_type_s_first_line_goes_where_the_file_lays_it_out`; checked locally against users' files: two election posters come 5.4% → 1.1% and 8.6% → 4.3% from their pages, so they open from their layers, and another 1.7% → 0.3%; scores unchanged | From 7ae9f2c4e56d4928b2cba1bfbf26bc0d4f8980f7 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:52:03 +0100 Subject: [PATCH 76/98] Keep an empty text value in dataset CSV export and import Found while testing crates/engine/src/cmd/variablecmds/csv.rs:146 (csv_escape) and :158-163 (cell_to_value) Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/variablecmds/csv.rs | 30 ++++++++++++++--------- crates/engine/src/tests/variables.rs | 29 ++++++++++++++++++++++ 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/variablecmds/csv.rs b/crates/engine/src/cmd/variablecmds/csv.rs index 95d80ce28e..bf60c95085 100644 --- a/crates/engine/src/cmd/variablecmds/csv.rs +++ b/crates/engine/src/cmd/variablecmds/csv.rs @@ -29,8 +29,8 @@ fn datasets_csv_bytes(p: &Value, cmd: &str) -> Result<Vec<u8>> { Ok(bytes) } -/// A row of a datasets CSV: the file line it starts on, and its cells. -type CsvRow = (usize, Vec<String>); +/// A row of a datasets CSV: the file line it starts on, its cells, and for each cell whether it was quoted. +type CsvRow = (usize, Vec<String>, Vec<bool>); /// Whether a datasets CSV separates its cells with tabs: its header row (the first line that /// isn't blank), read with tabs between cells, has a tab outside quotes and no comma outside @@ -80,7 +80,7 @@ fn tab_separated(text: &str) -> bool { fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<CsvRow>> { let text = text.strip_prefix('\u{feff}').unwrap_or(text); let sep = if tab_separated(text) { '\t' } else { ',' }; - let (mut rows, mut row, mut cur) = (Vec::new(), Vec::<String>::new(), String::new()); + let (mut rows, mut row, mut quoted, mut cur) = (Vec::new(), Vec::<String>::new(), Vec::<bool>::new(), String::new()); // `closed`: where the closing quote of a quoted cell falls in `cur`. `start`: the line the // current row starts on. let (mut in_quotes, mut closed, mut line, mut start) = (false, None, 1usize, 1usize); @@ -98,18 +98,23 @@ fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<CsvRow>> { } } Some('"') if cur.is_empty() => in_quotes = true, - Some(ch) if ch == sep && !in_quotes => row.push(end_csv_cell(&mut cur, &mut closed)), + Some(ch) if ch == sep && !in_quotes => { + quoted.push(closed.is_some()); + row.push(end_csv_cell(&mut cur, &mut closed)); + } Some('\r') if !in_quotes => {} Some('\n') | None if !in_quotes => { + quoted.push(closed.is_some()); row.push(end_csv_cell(&mut cur, &mut closed)); if row.iter().any(|c| !c.trim().is_empty()) { - rows.push((start, std::mem::take(&mut row))); + rows.push((start, std::mem::take(&mut row), std::mem::take(&mut quoted))); // +1 for the header. if rows.len() > MAX_CSV_ROWS + 1 { return Err(bad(cmd, format!("more than {MAX_CSV_ROWS} data rows"))); } } row.clear(); + quoted.clear(); if c.is_none() { return Ok(rows); } @@ -143,7 +148,8 @@ fn end_csv_cell(cur: &mut String, closed: &mut Option<usize>) -> String { } fn csv_escape(s: &str) -> String { - if s.contains([',', '"', '\n', '\r']) || s != s.trim() { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() } + // An empty string is quoted so that it reads back as an empty value, not as a missing one. + if s.is_empty() || s.contains([',', '"', '\n', '\r']) || s != s.trim() { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() } } /// A visibility cell: true/false, 1/0, yes/no (ASCII, case-insensitive). @@ -155,12 +161,13 @@ fn parse_visible_cell(s: &str, var: &str, cmd: &str) -> Result<bool> { } } -fn cell_to_value(kind: VariableKind, cell: &str, var: &str, cmd: &str) -> Result<Option<DataValue>> { +/// The value of a cell. A quoted empty text cell is an empty text value; any other empty cell is no value. +fn cell_to_value(kind: VariableKind, cell: &str, quoted: bool, var: &str, cmd: &str) -> Result<Option<DataValue>> { // A text cell is not trimmed here (the parser trims unquoted cells); image, graph and // visibility cells are trimmed. let t = if kind == VariableKind::Text { cell } else { cell.trim() }; if t.is_empty() { - return Ok(None); + return Ok((quoted && kind == VariableKind::Text).then(|| DataValue::Text(String::new()))); } Ok(Some(match kind { VariableKind::Text => DataValue::Text(t.to_string()), @@ -250,7 +257,7 @@ pub(super) fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> { let bytes = datasets_csv_bytes(p, C)?; let text = std::str::from_utf8(&bytes).map_err(|_| bad(C, "CSV is not UTF-8"))?; let rows = parse_datasets_csv(text, C)?; - let Some((_, header)) = rows.first() else { + let Some((_, header, _)) = rows.first() else { return Err(bad(C, "CSV is empty")); }; let name_column = header.first().is_some_and(|h| h.trim().eq_ignore_ascii_case("name")); @@ -280,7 +287,7 @@ pub(super) fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> { let mut parsed: Vec<DataSet> = Vec::new(); // The names of the rows read so far, and the last number given to a row without a name. let (mut seen, mut number) = (BTreeSet::new(), 0usize); - for &(line, ref row) in rows.iter().skip(1) { + for &(line, ref row, ref quoted) in rows.iter().skip(1) { let raw_name = if name_column { row.first().map_or("", |c| c.trim()) } else { "" }; let name = if raw_name.is_empty() { // Same numbering as Capture Data Set when a row leaves the name blank or the file has @@ -302,7 +309,8 @@ pub(super) fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> { let mut values = BTreeMap::new(); for &(col, ref var, kind) in &columns { let cell = row.get(col).map(String::as_str).unwrap_or(""); - match cell_to_value(kind, cell, var, C) { + let was_quoted = quoted.get(col).copied().unwrap_or(false); + match cell_to_value(kind, cell, was_quoted, var, C) { Ok(Some(v)) => { values.insert(var.clone(), v); } diff --git a/crates/engine/src/tests/variables.rs b/crates/engine/src/tests/variables.rs index 281d575156..bdacd0e1bf 100644 --- a/crates/engine/src/tests/variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -1103,6 +1103,35 @@ fn datasets_csv_keeps_line_breaks_in_quoted_cells() { assert!(s.execute("dataset.import", &json!({"csv": "name,Name\nX,\"open"})).is_err(), "unclosed quote"); } +/// An empty text value is not a missing value: it comes back from the exported CSV as an empty +/// text, so selecting the data set still clears the bound text. +#[test] +fn datasets_csv_keeps_an_empty_text_value() { + let mut s = session(); + let t = text(&mut s, "One"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "A", "values": {"Name": ""}})).unwrap(); + let csv = s.execute("dataset.export", &json!({})).unwrap()["csv"].as_str().unwrap().to_string(); + s.execute("dataset.delete", &json!({"names": ["A"]})).unwrap(); + s.execute("dataset.import", &json!({"csv": csv})).unwrap(); + let value = s.doc().unwrap().doc.variables.dataset("A").and_then(|d| d.values.get("Name")).cloned(); + assert_eq!(value, Some(vectorcraft_doc::DataValue::Text(String::new()))); + s.execute("dataset.import", &json!({"csv": "name,Name\nB,\nC,\"\"\n"})).unwrap(); + let value = |name: &str| s.doc().unwrap().doc.variables.dataset(name).and_then(|d| d.values.get("Name")).cloned(); + assert_eq!(value("C"), Some(vectorcraft_doc::DataValue::Text(String::new()))); + // Compatibility rule for files written before empty text was quoted: a bare empty cell stays "no value". + assert_eq!(value("B"), None); + // A data set with no value for the variable exports a bare cell and stays without a value. + s.execute("dataset.new", &json!({"name": "D", "values": {}})).unwrap(); + let csv = s.execute("dataset.export", &json!({})).unwrap()["csv"].as_str().unwrap().to_string(); + assert!(csv.contains("\nD,\n"), "{csv:?}"); + s.execute("dataset.delete", &json!({"names": ["D"]})).unwrap(); + s.execute("dataset.import", &json!({"csv": csv})).unwrap(); + let value = |name: &str| s.doc().unwrap().doc.variables.dataset(name).and_then(|d| d.values.get("Name")).cloned(); + assert_eq!(value("D"), None); +} + /// A quoted text cell keeps its leading and trailing spaces, so a padded value comes back from /// the exported CSV as it went out; an unquoted cell, and spaces after a closing quote, are trimmed. #[test] From 1679318034a243c258763465a7a011520bb493b1 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:39:40 +0300 Subject: [PATCH 77/98] Type: lam-alef highlight follow-up: optional features, all marks, exact ligature test (#394) Highlight Alternate Glyphs no longer drops a lam-alef cluster's glyphs outright: the ligature is clear when required shaping, or shaping with liga left on (older fonts), gives it, so an optional feature such as salt that changes it is highlighted again. is_lam_alef takes any combining mark by Unicode general category (Arabic Extended-A/B/C included) instead of a partial list. The test font maps the fatha to a mark and skips marks in its ligature, so the mark-spanning test checks the exact ligature glyph and cluster; it gains a salt lookup and test. Also a one-line equivalent nonminimal_bool repair in pathops' pathtools that strict Clippy needs on main. --- Cargo.lock | 1 + crates/render/Cargo.toml | 2 + crates/render/src/tests/alternate.rs | 75 ++++++++++++++++++++++++---- crates/render/src/text_geom.rs | 47 +++++++++++------ crates/text/src/test_fonts.rs | 36 +++++++++---- docs/type-parity.md | 5 +- 6 files changed, 129 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ff1d18be21..a1512c6d0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6383,6 +6383,7 @@ dependencies = [ "psd", "serde_json", "tiff", + "unicode-general-category", "vectorcraft-brush", "vectorcraft-color", "vectorcraft-doc", diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 6d92638452..4bd499b9d4 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -27,6 +27,8 @@ weezl = "0.2" # PNG rows read one at a time (MIT OR Apache-2.0; already used by the image crate's PNG reader). png = "0.18" log = { workspace = true } +# Combining marks inside a lam-alef cluster (MIT OR Apache-2.0; already used by vectorcraft-text). +unicode-general-category = "1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] vello_cpu = { workspace = true, features = ["multithreading"] } diff --git a/crates/render/src/tests/alternate.rs b/crates/render/src/tests/alternate.rs index 8ef6903301..201efc3299 100644 --- a/crates/render/src/tests/alternate.rs +++ b/crates/render/src/tests/alternate.rs @@ -136,17 +136,49 @@ fn lam_alef_is_not_an_alternate() { /// feature. Built in code, so it runs whatever fonts the machine has. #[test] fn lam_alef_in_liga_is_not_an_alternate() { - use vectorcraft_text::test_fonts::{lam_alef_family, lam_alef_font}; + use vectorcraft_text::test_fonts::{lam_alef_family, lam_alef_font, source_sans_gid}; let _adding = crate::tests_fontchange::adding_fonts(); let db = vectorcraft_text::FontDb::global(); + let gid = |c| u32::from(source_sans_gid(c).unwrap()); + // Text, the ligature glyph and its cluster (byte, length): lam + alef, lam + alef with hamza + // above, the pair after a beh, and the pair with a fatha on the lam (the ligature skips it). + let cases = [ + ("\u{0644}\u{0627}", 'H', (0, 4)), + ("\u{0644}\u{0623}", 'K', (0, 4)), + ("\u{0628}\u{0644}\u{0627}", 'H', (2, 4)), + ("\u{0644}\u{064E}\u{0627}", 'H', (0, 6)), + ]; for feature in [b"liga", b"rlig"] { db.add_font(lam_alef_font(feature).unwrap()); - let style = vectorcraft_doc::text::CharStyle { size: 36.0, font_family: lam_alef_family(feature).into(), ..Default::default() }; - for s in ["\u{0644}\u{0627}", "\u{0644}\u{0623}", "\u{0628}\u{0644}\u{0627}", "\u{0644}\u{064E}\u{0627}"] { + let family = lam_alef_family(feature); + let style = vectorcraft_doc::text::CharStyle { size: 36.0, font_family: family.into(), ..Default::default() }; + for (s, ligature, (byte, len)) in cases { let t = text(s, style.clone()); - // The font's ligature glyph stands for the pair. - assert!(glyphs(&t).iter().any(|g| g.1 > 2), "{}: {s} is shaped to one ligature glyph", lam_alef_family(feature)); - assert_eq!(alternates(&t), 0, "{}: {s}", lam_alef_family(feature)); + // The font's ligature glyph stands for the pair, its cluster lam to alef. + let shaped = glyphs(&t); + assert!(shaped.contains(&(byte, len, gid(ligature))), "{family}: {s} is shaped to {ligature} over {byte}..{}: {shaped:?}", byte + len); + assert_eq!(alternates(&t), 0, "{family}: {s}"); + } + } +} + +/// #394: an optional feature that changes the lam-alef ligature (here `salt`) is highlighted: +/// only the required ligature itself is left clear, whichever feature the font files it under. +#[test] +fn an_optional_feature_on_the_lam_alef_ligature_is_an_alternate() { + use vectorcraft_text::test_fonts::{LAM_ALEF_SALT, lam_alef_family, lam_alef_font, source_sans_gid}; + let _adding = crate::tests_fontchange::adding_fonts(); + let db = vectorcraft_text::FontDb::global(); + let salted = u32::from(source_sans_gid(LAM_ALEF_SALT).unwrap()); + for feature in [b"liga", b"rlig"] { + db.add_font(lam_alef_font(feature).unwrap()); + let family = lam_alef_family(feature); + let style = vectorcraft_doc::text::CharStyle { size: 36.0, font_family: family.into(), features: vec!["salt".into()], ..Default::default() }; + for (s, byte) in [("\u{0644}\u{0627}", 0), ("\u{0628}\u{0644}\u{0627}", 2)] { + let t = text(s, style.clone()); + let shaped = glyphs(&t); + assert!(shaped.contains(&(byte, 4, salted)), "{family}: salt turns the ligature into {LAM_ALEF_SALT}: {shaped:?}"); + assert_eq!(alternates(&t), 1, "{family}: {s}: the salted ligature, and nothing else"); } } } @@ -154,11 +186,36 @@ fn lam_alef_in_liga_is_not_an_alternate() { /// Only a lam followed by an alef (marks between or after allowed) is the required lam-alef. #[test] fn only_lam_alef_clusters_are_the_required_ligature() { - for s in ["\u{0644}\u{0627}", "\u{0644}\u{0622}", "\u{0644}\u{0623}", "\u{0644}\u{0625}", "\u{0644}\u{064E}\u{0627}", "\u{0644}\u{0627}\u{064B}"] - { + // Marks of the Arabic block, Arabic Extended-A (U+08E4, U+08F0), Extended-B (U+0898) and + // Extended-C (U+10EFD), and Quranic marks (U+0610, U+06ED). + for s in [ + "\u{0644}\u{0627}", + "\u{0644}\u{0622}", + "\u{0644}\u{0623}", + "\u{0644}\u{0625}", + "\u{0644}\u{064E}\u{0627}", + "\u{0644}\u{0627}\u{064B}", + "\u{0644}\u{08E4}\u{0627}", + "\u{0644}\u{08F0}\u{0627}", + "\u{0644}\u{0898}\u{0627}", + "\u{0644}\u{0627}\u{10EFD}", + "\u{0644}\u{0610}\u{06ED}\u{0627}", + ] { assert!(crate::text_geom::is_lam_alef(s), "{s:?}"); } - for s in ["", "\u{0644}", "\u{0627}\u{0644}", "\u{0644}\u{0628}", "\u{0628}\u{0644}\u{0627}", "\u{0644}\u{0627}\u{0627}", "fi", "\u{064E}"] { + // Not marks: tatweel (U+0640) and the number mark above (U+08E2, a format character). + for s in [ + "", + "\u{0644}", + "\u{0627}\u{0644}", + "\u{0644}\u{0628}", + "\u{0628}\u{0644}\u{0627}", + "\u{0644}\u{0627}\u{0627}", + "fi", + "\u{064E}", + "\u{0644}\u{0640}\u{0627}", + "\u{0644}\u{08E2}\u{0627}", + ] { assert!(!crate::text_geom::is_lam_alef(s), "{s:?}"); } } diff --git a/crates/render/src/text_geom.rs b/crates/render/src/text_geom.rs index bf058d75fc..0d65dfbffd 100644 --- a/crates/render/src/text_geom.rs +++ b/crates/render/src/text_geom.rs @@ -48,17 +48,28 @@ impl TextGeom { /// The boxes of the glyphs of `t` (the text this geometry is of) that optional OpenType features /// chose (Preferences › Highlight Alternate Glyphs): candidates the text's required shaping /// alone doesn't give ([`required_shaping`]). Arabic letters joining, Indic conjuncts and the - /// lam-alef ligature are what the script needs, not alternates (the lam-alef ligature also when - /// a font files it under `liga`: [`is_lam_alef`]). Measured once. + /// lam-alef ligature are what the script needs, not alternates. A font may file the lam-alef + /// ligature under `liga`, which required shaping turns off, so a lam-alef cluster + /// ([`is_lam_alef`]) is also compared with its shaping with `liga` left on: an optional feature + /// that changes its glyph beyond that (a stylistic alternate…) is still highlighted. Measured + /// once. pub(crate) fn alternate_glyphs(&self, t: &TextObject) -> &BezPath { self.alternate_glyphs.get_or_init(|| { let mut out = BezPath::new(); if self.alternate_candidates.is_empty() { return out; } - let required = required_shaping(t); + let required = required_shaping(t, &["-liga", "-calt"]); + let plain = t.plain_text(); + let in_lam_alef = |c: &AlternateCandidate| plain.get(c.byte..c.byte.saturating_add(c.len)).is_some_and(is_lam_alef); + // Laid out only when the text has a lam-alef candidate. + let with_liga = self.alternate_candidates.iter().any(in_lam_alef).then(|| required_shaping(t, &["-calt"])); + let gives = |map: &std::collections::HashMap<(usize, usize), Vec<(u32, u32)>>, c: &AlternateCandidate| { + map.get(&(c.byte, c.len)).is_some_and(|glyphs| glyphs.contains(&(c.font_id, c.gid))) + }; for c in &self.alternate_candidates { - if !required.get(&(c.byte, c.len)).is_some_and(|glyphs| glyphs.contains(&(c.font_id, c.gid))) { + let lam_alef_ligature = || in_lam_alef(c) && with_liga.as_ref().is_some_and(|m| gives(m, c)); + if !gives(&required, c) && !lam_alef_ligature() { out.extend(c.cell.iter()); } } @@ -86,12 +97,14 @@ pub(crate) fn is_opentype_alternate(nominal_gid: u32, shaped_gid: u32, cluster_c } /// Whether `cluster` is a lam followed by an alef (plain, with madda, or with hamza above or below), -/// with any Arabic marks between or after them: the lam-alef ligature, which the Unicode -/// Standard's Arabic chapter treats as obligatory, not a stylistic choice. Fonts made before -/// `rlig` was common put it in `liga`, which [`required_shaping`] turns off, so it is required -/// by its characters whatever feature the font files it under. +/// with any combining marks (Unicode general category M: the Arabic harakat, Quranic marks and +/// those of Arabic Extended-A, -B and -C) between or after them: the lam-alef ligature, which the +/// Unicode Standard's Arabic chapter treats as obligatory, not a stylistic choice. Fonts made +/// before `rlig` was common put it in `liga`, which [`required_shaping`] turns off, so +/// [`TextGeom::alternate_glyphs`] also accepts its glyph from shaping with `liga` on. pub(crate) fn is_lam_alef(cluster: &str) -> bool { - let is_mark = |c: &char| matches!(c, '\u{0610}'..='\u{061A}' | '\u{064B}'..='\u{065F}' | '\u{0670}' | '\u{06D6}'..='\u{06DC}' | '\u{06DF}'..='\u{06E4}' | '\u{06E7}' | '\u{06E8}' | '\u{06EA}'..='\u{06ED}'); + use unicode_general_category::{GeneralCategory as G, get_general_category}; + let is_mark = |c: &char| matches!(get_general_category(*c), G::NonspacingMark | G::SpacingMark | G::EnclosingMark); let mut letters = cluster.chars().filter(|c| !is_mark(c)); matches!((letters.next(), letters.next(), letters.next()), (Some('\u{0644}'), Some('\u{0622}' | '\u{0623}' | '\u{0625}' | '\u{0627}'), None)) } @@ -108,14 +121,16 @@ pub(crate) struct AlternateCandidate { } /// The glyphs `t` is shaped to by required shaping alone, by cluster (`byte`, `len`) → `(font, -/// glyph)`: laid out again with every optional feature off (standard and contextual ligatures and -/// alternates, and the features its characters turn on: stylistic sets, swashes, discretionary -/// ligatures, figure styles…). What is left is what the script needs: `ccmp`, `locl`, `rlig`, -/// Arabic's `init`, `medi`, `fina` and `isol`, the Indic and other complex scripts' required sets. -fn required_shaping(t: &TextObject) -> std::collections::HashMap<(usize, usize), Vec<(u32, u32)>> { +/// glyph)`: laid out again with every optional feature off (with `features` `-liga` and `-calt`: +/// standard and contextual ligatures and alternates, and the features its characters turn on: +/// stylistic sets, swashes, discretionary ligatures, figure styles…). What is left is what the +/// script needs: `ccmp`, `locl`, `rlig`, Arabic's `init`, `medi`, `fina` and `isol`, the Indic +/// and other complex scripts' required sets. With `features` `-calt` alone, standard ligatures +/// stay on (an older font's lam-alef). +fn required_shaping(t: &TextObject, features: &[&str]) -> std::collections::HashMap<(usize, usize), Vec<(u32, u32)>> { let mut plain = t.clone(); for run in &mut plain.runs { - run.style.features = vec!["-liga".into(), "-calt".into()]; + run.style.features = features.iter().map(|f| (*f).into()).collect(); } let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &plain); let mut glyphs: std::collections::HashMap<(usize, usize), Vec<(u32, u32)>> = std::collections::HashMap::new(); @@ -193,7 +208,7 @@ pub(crate) fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeo let cluster_chars = cluster.chars().count(); let Some(first) = cluster.chars().next() else { continue }; let nominal = db.face_by_id(g.font_id).map(|f| f.glyph_for(first)).unwrap_or(0); - if is_opentype_alternate(nominal, g.gid, cluster_chars) && !is_lam_alef(cluster) { + if is_opentype_alternate(nominal, g.gid, cluster_chars) { alternate_candidates.push(AlternateCandidate { byte: g.byte, len: g.len, font_id: g.font_id, gid: g.gid, cell }); } } diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 449221f9f1..f3619679ff 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -352,21 +352,29 @@ pub fn lam_alef_family(feature: &[u8; 4]) -> &'static str { } } +/// The glyph [`lam_alef_font`]'s `salt` gives its lam + alef ligature (H): M. +pub const LAM_ALEF_SALT: char = 'M'; + /// A test-only Arabic cmap over bundled Source Sans 3: lam uses l, alef I, alef with hamza above -/// J and beh b. The `GSUB` table's `arab` script has one ligature lookup, under `feature`, that -/// joins lam + alef into H and lam + alef with hamza above into K. Older fonts put the mandatory -/// lam-alef ligature in `liga` rather than `rlig`; this builds either, without an OS font. `None` -/// when it can't be built. +/// J, beh b and fatha the combining acute (a mark in the font's `GDEF`). The `GSUB` table's `arab` +/// script has one ligature lookup, under `feature`, that skips marks and joins lam + alef into H +/// and lam + alef with hamza above into K, and a `salt` lookup that turns H into +/// [`LAM_ALEF_SALT`]. Older fonts put the mandatory lam-alef ligature in `liga` rather than +/// `rlig`; this builds either, without an OS font. `None` when it can't be built. pub fn lam_alef_font(feature: &[u8; 4]) -> Option<Vec<u8>> { use write_fonts::tables::cmap::Cmap; - 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::tables::gsub::{ + Gsub, Ligature, LigatureSet, LigatureSubstFormat1, SingleSubstFormat2, SubstitutionLookup, SubstitutionLookupList, + }; + use write_fonts::tables::layout::{ + CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, LookupFlag, Script, ScriptList, ScriptRecord, + }; use write_fonts::types::GlyphId16; const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); let font = skrifa::FontRef::new(BASE).ok()?; let charmap = font.charmap(); - let mapping = [('\u{0644}', 'l'), ('\u{0627}', 'I'), ('\u{0623}', 'J'), ('\u{0628}', 'b')]; + let mapping = [('\u{0644}', 'l'), ('\u{0627}', 'I'), ('\u{0623}', 'J'), ('\u{0628}', 'b'), ('\u{064E}', '\u{0301}')]; let mappings = mapping.into_iter().map(|(c, source)| Some((c, write_fonts::types::GlyphId::new(charmap.map(source)?.to_u32())))); let cmap = Cmap::from_mappings(mappings.collect::<Option<Vec<_>>>()?).ok()?; let family = lam_alef_family(feature); @@ -380,9 +388,17 @@ pub fn lam_alef_font(feature: &[u8; 4]) -> Option<Vec<u8>> { let coverage = CoverageFormat1::new(vec![gid('l')?]); let ligatures = vec![Ligature::new(gid('H')?, vec![gid('I')?]), Ligature::new(gid('K')?, vec![gid('J')?])]; let subst = LigatureSubstFormat1::new(coverage.into(), vec![LigatureSet::new(ligatures)]); - let features = FeatureList::new(vec![FeatureRecord::new(Tag::new(feature), Feature::new(None, vec![0]))]); - let lookups = SubstitutionLookupList::new(vec![SubstitutionLookup::Ligature(Lookup::new(Default::default(), vec![subst]))]); - let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"arab"), Script::new(Some(LangSys::new(vec![0])), vec![]))]); + let salt = SingleSubstFormat2::new(CoverageFormat1::new(vec![gid('H')?]).into(), vec![gid(LAM_ALEF_SALT)?]); + // Features sorted by tag, as OpenType requires: `liga` / `rlig`, then `salt`. + let features = FeatureList::new(vec![ + FeatureRecord::new(Tag::new(feature), Feature::new(None, vec![0])), + FeatureRecord::new(Tag::new(b"salt"), Feature::new(None, vec![1])), + ]); + let lookups = SubstitutionLookupList::new(vec![ + SubstitutionLookup::Ligature(Lookup::new(LookupFlag::IGNORE_MARKS, vec![subst])), + SubstitutionLookup::Single(Lookup::new(Default::default(), vec![salt.into()])), + ]); + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"arab"), Script::new(Some(LangSys::new(vec![0, 1])), vec![]))]); builder.add_table(&Gsub::new(scripts, features, lookups)).ok()?; for r in font.table_directory.table_records() { let tag = Tag::new(&r.tag().to_be_bytes()); diff --git a/docs/type-parity.md b/docs/type-parity.md index c0220b1933..dbf195b8a8 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Highlight Alternate Glyphs leaves the required lam-alef ligature unhighlighted also in older Arabic fonts (such as legacy Arial) that put it in `liga` rather than `rlig` (#394); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Highlight Alternate Glyphs: an optional feature (such as `salt`) that changes a lam-alef ligature is highlighted again, only the required ligature staying clear, and a lam-alef with any combining mark (Arabic Extended-A/B/C included) counts as the ligature (#394); scores unchanged) · **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 @@ -28,7 +28,7 @@ against Illustrator's public documentation; **estimated** unless noted. | 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 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); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607) | +| 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) | | 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) | | 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 | @@ -71,6 +71,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Highlight Alternate Glyphs: an optional feature (such as `salt`) that changes a lam-alef ligature is highlighted again, only the required ligature staying clear, and a lam-alef with any combining mark (Arabic Extended-A/B/C included) counts as the ligature (#394); scores unchanged | | 2026-10-30 | minor | Highlight Alternate Glyphs leaves the required lam-alef ligature unhighlighted also in older Arabic fonts (such as legacy Arial) that put it in `liga` rather than `rlig` (#394); scores unchanged | | 2026-10-30 | minor | Character panel menu › Show Snap to Glyph Options shows the Snap to Glyph section (Baseline, x-height, Glyph Bounds, Proximity Guides, Angular Guides, Anchor Point; `view.snapToGlyph.options`) (#4232); scores unchanged | | 2026-10-29 | minor | Font fallback keeps a letter and its combining marks in one face; Vietnamese stacked diacritics (Ể, Ứ, Ự) attach in precomposed, decomposed and keyboard forms (#4491); scores and hours unchanged | From 2e7b2b7f96478842cacdd4272261c70b4b6f5b5e Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:12:24 +0100 Subject: [PATCH 78/98] SVG import: an embedded SVG image gets the feConvolveMatrix order check too Files::svg parsed nested SVG images with usvg without the check the top-level import makes, so an overflowing order inside an <image> still reached usvg's multiply. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/svg/src/import/files.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs index 722d043d44..b6d488c9ea 100644 --- a/crates/svg/src/import/files.rs +++ b/crates/svg/src/import/files.rs @@ -287,8 +287,10 @@ impl Files { if depth::xml_deeper_than(text, left) { return leave_out(&self.too_deep, true); } - let height = depth::height(&super::parse(text).ok()?, left); - let Some(height) = height else { return leave_out(&self.too_deep, true) }; + let xml = super::parse(text).ok()?; + let Some(height) = depth::height(&xml, left) else { return leave_out(&self.too_deep, true) }; + // usvg's parser would overflow on it, as on the SVG holding it (`import`). + super::convolve_matrices_ok(&xml).ok()?; self.note_text(text); self.depth.store(used + height, Ordering::Relaxed); let tree = usvg::Tree::from_data_nested(text.as_bytes(), opts); @@ -462,6 +464,22 @@ mod tests { assert_eq!(read(MAX_ELEMENT_DEPTH, 0), (false, 1)); } + /// An SVG image with a `feConvolveMatrix` order usvg's multiply overflows is left out before + /// usvg reads it, as the SVG holding it would be refused. + #[test] + fn svg_images_with_an_overflowing_convolve_order_are_left_out() { + let read = |order: &str| { + let svg = format!( + r#"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"><filter id="f"><feConvolveMatrix order="{order}" kernelMatrix="1"/></filter><rect width="4" height="4" filter="url(#f)"/></svg>"# + ); + let files = Files::default(); + let resolver = files.resolver(1); + matches!((resolver.resolve_data)("image/svg+xml", Arc::new(svg.into_bytes()), &usvg::Options::default()), Some(ImageKind::SVG(_))) + }; + assert!(!read("2147483647")); + assert!(read("1")); + } + #[test] fn the_placeholder_is_a_png() { let png = marker().unwrap(); From 9efe6dc76462fed745ed303a02cce1de345b916c Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Sat, 31 Oct 2026 18:56:19 +0700 Subject: [PATCH 79/98] PDF import: a font name's family is looked for at no more than 16 of its hyphens The name comes from the file, and each lookup walks it, so a name made of thousands of hyphens took quadratic time. --- crates/pdf/src/import_text.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index 373437cfa4..8157759e7d 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -985,8 +985,9 @@ impl Families { } // The longest part of the name before a hyphen (or the comma of `Arial,Bold`, ISO 32000-1 // §9.6.3) that names an available family, the rest its style: a family can hold hyphens - // (`Noto-Sans-Bold` is Noto Sans, Bold). - let cuts = std::iter::once(name.len()).chain(name.rmatch_indices(['-', ',']).map(|(i, _)| i)); + // (`Noto-Sans-Bold` is Noto Sans, Bold). The name is the file's: a few cuts from its end are + // enough for any family, and each costs a pass over the name. + let cuts = std::iter::once(name.len()).chain(name.rmatch_indices(['-', ',']).map(|(i, _)| i).take(16)); for cut in cuts { let (fam, style) = (name.get(..cut).unwrap_or(name), name.get(cut + 1..).unwrap_or("")); if let Some(f) = [fam, strip_ps(fam)].iter().find_map(|f| self.0.get(&norm(f)).cloned()) { From 13bac047f35bcf46a8d5e645ad33188b79278cf9 Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Sat, 31 Oct 2026 18:56:43 +0700 Subject: [PATCH 80/98] EPS import: spend keeps its own doc comment --- crates/eps/src/import/interp.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 68053f29bd..12232624f8 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -333,14 +333,14 @@ impl<'a> Interp<'a> { self.spend(1) } - /// Count `ops` operations' worth of work against [`MAX_OPS`]: what an operator that costs - /// far more than one operation (laying out type) does in a loop has to end too. /// Charge for work on `n` bytes or objects in one operator: one operation per 256, as `search` /// charges per 64 bytes, so a loop of big copies runs out of the budget. fn spend_bytes(&mut self, n: usize) -> Res { self.spend((n / 256) as u64) } + /// Count `ops` operations' worth of work against [`MAX_OPS`]: what an operator that costs + /// far more than one operation (laying out type) does in a loop has to end too. pub fn spend(&mut self, ops: u64) -> Res { self.ops = self.ops.saturating_add(ops); if self.ops > MAX_OPS { From 437280315f9ba7e9732faa0d79213e8e736d71b7 Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Sat, 31 Oct 2026 18:42:49 +0700 Subject: [PATCH 81/98] Fix #4803: Enter or Escape in a floating panel's field stays in the field --- crates/ui-egui/src/panel_docking.rs | 15 ++++++++- .../ui-egui/src/panel_docking/native_tests.rs | 31 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index d19b7be2c9..cdec00eaad 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -1187,6 +1187,14 @@ fn forward_keys(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui: ctx.request_repaint(); } +/// A field of a floating panel's window that ends its editing on Enter or Escape lets go of the +/// keyboard in that frame, so craft-ui would forward the key to the main window's shortcuts: Enter +/// in the Transparency panel's Opacity field also opened the Move dialog (#4803). The key was the +/// field's, so it stays in the window. +fn keep_field_ending_keys(ui: &egui::Ui) { + ui.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { key: egui::Key::Enter | egui::Key::Escape, pressed: true, .. }))); +} + /// VectorCraft's look and wording of craft-ui's docking. fn dock_style(ui: &egui::Ui) -> craft_ui::docking::DockStyle { let t = crate::theme::Tokens::get(ui.ctx()); @@ -1244,9 +1252,11 @@ impl craft_ui::docking::DockContent<String> for PanelContent<'_> { self.app.canvas_area(ui); return; } - if ui.ctx().viewport_id() != egui::ViewportId::ROOT { + let floating = ui.ctx().viewport_id() != egui::ViewportId::ROOT; + if floating { crate::canvas::release_art_in_window(self.app, ui.ctx()); } + let field_had_keyboard = floating && ui.memory(|m| m.focused().is_some()); let t = crate::theme::Tokens::get(ui.ctx()); let back = ui.max_rect(); if back.is_positive() { @@ -1263,6 +1273,9 @@ impl craft_ui::docking::DockContent<String> for PanelContent<'_> { crate::dock::panel_body(self.app, ui, id, width, height) }); } + if field_had_keyboard { + keep_field_ending_keys(ui); + } } fn logical_label(&self, id: &String) -> String { diff --git a/crates/ui-egui/src/panel_docking/native_tests.rs b/crates/ui-egui/src/panel_docking/native_tests.rs index 41edab720a..ab47205743 100644 --- a/crates/ui-egui/src/panel_docking/native_tests.rs +++ b/crates/ui-egui/src/panel_docking/native_tests.rs @@ -240,6 +240,37 @@ fn keys_typed_in_a_native_window_run_the_main_windows_shortcuts() { assert!(h.app.session.active().unwrap().selection.objects.is_empty()); } +/// Enter that commits a field of a floating panel's window, and Escape that cancels one, are the +/// field's: with an object selected and the Selection tool, Enter didn't also open the Move dialog +/// (#4803). +#[test] +fn enter_and_escape_in_a_native_windows_field_stay_in_the_field() { + let mut h = Harness::new(); + h.app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + h.app.run("select.all", json!({})).unwrap(); + let viewport = h.float("transparency"); + // The window's first text field (Opacity), by the state its text box keeps. + let ctx = h.ctx.clone(); + let field = ctx + .viewport_for(viewport, |vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).map(|w| (w.id, w.rect)).collect::<Vec<_>>()) + .into_iter() + .find(|(id, _)| egui::TextEdit::load_state(&ctx, *id).is_some()) + .map(|(_, rect)| rect.center()) + .expect("the panel has a text field"); + for key in [Key::Enter, Key::Escape] { + if let Some(window) = h.host.borrow_mut().windows.get_mut(&viewport) { + window.events.extend([button(field, true), button(field, false)]); + } + h.frames(2); + h.key_in(viewport, key); + assert!(h.app.ui.dialog.is_none(), "{key:?} in the field opens no dialog"); + assert_eq!(h.app.session.active().unwrap().selection.objects.len(), 1, "{key:?} in the field keeps the selection"); + } + // Keys typed with no field editing still reach the main window. + h.key_in(viewport, Key::Enter); + assert!(h.app.ui.dialog.is_some(), "Enter outside a field opens the Move dialog"); +} + #[test] fn a_modal_blocks_native_windows_and_takes_the_keyboard_from_them() { let mut h = Harness::new(); From 78a68bd8ecf0279b48ec76d16d8da781a2a3b3e7 Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Sat, 31 Oct 2026 19:01:14 +0700 Subject: [PATCH 82/98] Fix #4835: Artwork Cleanup's Duplicate Objects finds type set twice in the same place --- crates/engine/src/cmd/addons/cleanup.rs | 2 +- .../engine/src/cmd/addons/cleanup/checks.rs | 35 +++++++++++++++++-- crates/engine/src/cmd/addons/cleanup/tests.rs | 20 +++++++++++ docs/mcp.md | 2 +- 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/addons/cleanup.rs b/crates/engine/src/cmd/addons/cleanup.rs index 4f96c26346..b65fc75ac3 100644 --- a/crates/engine/src/cmd/addons/cleanup.rs +++ b/crates/engine/src/cmd/addons/cleanup.rs @@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> { "Health Check", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{scope?: \"selection\"|\"document\" (default: the selection, or the document while nothing is selected), kinds?: [kind…] (default: all: emptyText, strayPoints (paths of one anchor), unpainted, zeroOpacity, duplicateObjects (a path stacked on an identical one), offArtboard, duplicateAnchors (neighbours within 0.01 pt), tinyHandles (under 0.01 pt), collinearAnchors (corners on a straight run, turning less than sine 0.005), nearPoints (anchors within 0.01 pt of other paths' anchors), nearlyClosed (ends within 0.01 pt), filledOpenPaths (filled, unstroked open paths), needlessClipGroups (an unpainted clip that clips nothing, hides everything, or is a rectangle round all its art), needlessCompounds (one path or none), needlessGroups (one member or none, nothing of their own), emptyArtboards (the document only))} find issues without changing anything; locked and hidden objects are left out → {scope, total, findings: [{kind, label, count, ids (the objects, at most 1000), artboards (indexes)}]} for the kinds found", + "{scope?: \"selection\"|\"document\" (default: the selection, or the document while nothing is selected), kinds?: [kind…] (default: all: emptyText, strayPoints (paths of one anchor), unpainted, zeroOpacity, duplicateObjects (a path stacked on an identical one, or type on the same type within 1 pt), offArtboard, duplicateAnchors (neighbours within 0.01 pt), tinyHandles (under 0.01 pt), collinearAnchors (corners on a straight run, turning less than sine 0.005), nearPoints (anchors within 0.01 pt of other paths' anchors), nearlyClosed (ends within 0.01 pt), filledOpenPaths (filled, unstroked open paths), needlessClipGroups (an unpainted clip that clips nothing, hides everything, or is a rectangle round all its art), needlessCompounds (one path or none), needlessGroups (one member or none, nothing of their own), emptyArtboards (the document only))} find issues without changing anything; locked and hidden objects are left out → {scope, total, findings: [{kind, label, count, ids (the objects, at most 1000), artboards (indexes)}]} for the kinds found", has_doc, check ), diff --git a/crates/engine/src/cmd/addons/cleanup/checks.rs b/crates/engine/src/cmd/addons/cleanup/checks.rs index 46c81293dd..c3abd029be 100644 --- a/crates/engine/src/cmd/addons/cleanup/checks.rs +++ b/crates/engine/src/cmd/addons/cleanup/checks.rs @@ -235,17 +235,46 @@ fn is_plain(n: &Node) -> bool { && *n.extras() == NodeExtras::default() } -/// Is `n` a plain path (full opacity, normal blending) identical to the object just below it? +/// Is `n` a plain path or type object (full opacity, normal blending) that doubles the object just +/// below it? fn duplicates_the_one_below(d: &Document, n: &Node) -> bool { - if !matches!(n.kind, NodeKind::Path { .. }) || n.opacity < 1.0 || n.blend != BlendMode::Normal { + if n.opacity < 1.0 || n.blend != BlendMode::Normal { return false; } let below = d.position(n.id).and_then(|(parent, i, _)| d.children(parent)?.get(i.checked_sub(1)?).cloned()); below.is_some_and(|b| { - b.visible && b.kind == n.kind && b.appearance == n.appearance && b.opacity == n.opacity && b.blend == n.blend && b.extras() == n.extras() + b.visible + && same_art(&b.kind, &n.kind) + && b.appearance == n.appearance + && b.opacity == n.opacity + && b.blend == n.blend + && b.extras() == n.extras() }) } +/// Distance (pt) within which doubled type counts as stacked: text set twice from a PDF or an old +/// file, or for a fake bold, is often a fraction of a point off. +const DOUBLED_TYPE: f64 = 1.0; + +/// The same path, or the same type (text, styles, size and turn) within [`DOUBLED_TYPE`] of it. +fn same_art(a: &NodeKind, b: &NodeKind) -> bool { + match (a, b) { + (NodeKind::Path { .. }, NodeKind::Path { .. }) => a == b, + (NodeKind::Text(a), NodeKind::Text(b)) => { + let ([a0, a1, a2, a3, ax, ay], [b0, b1, b2, b3, bx, by]) = (a.xf.as_coeffs(), b.xf.as_coeffs()); + [a0, a1, a2, a3] == [b0, b1, b2, b3] && (ax - bx).hypot(ay - by) <= DOUBLED_TYPE && { + // `b` placed on `a`, with `a`'s layout caches (they are in document space). + let mut moved = (**b).clone(); + moved.xf = a.xf; + moved.cached_bounds = a.cached_bounds; + moved.cached_baselines.clone_from(&a.cached_baselines); + **a == moved + } + } + _ => false, + } +} + fn inside(a: Rect, b: Rect) -> bool { a.x0 >= b.x0 && a.y0 >= b.y0 && a.x1 <= b.x1 && a.y1 <= b.y1 } diff --git a/crates/engine/src/cmd/addons/cleanup/tests.rs b/crates/engine/src/cmd/addons/cleanup/tests.rs index 3351dd7550..d02e1e4c09 100644 --- a/crates/engine/src/cmd/addons/cleanup/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/tests.rs @@ -226,6 +226,26 @@ fn each_check_finds_and_fixes_its_issue_and_leaves_clean_art_alone() { } } +/// Type set twice in the same place (from a PDF, or for a fake bold) is a duplicate within a point: +/// Show selects the upper copy and Fix removes it. Other text, or the same text further off, stays. +#[test] +fn doubled_type_within_a_point_is_a_duplicate() { + let mut s = session(); + let text = |s: &mut Session, x: f64, text: &str| id_of(&s.execute("text.create", &json!({"x": x, "y": 100, "text": text})).unwrap()); + let base = text(&mut s, 20.0, "Total"); + let doubled = text(&mut s, 20.4, "Total"); + let other = text(&mut s, 20.0, "Subtotal"); + let far = text(&mut s, 23.0, "Subtotal"); + assert_eq!(found(&mut s, "duplicateObjects", "document"), 1); + let shown = s.execute("addon.cleanup.show", &json!({"kind": "duplicateObjects", "scope": "document"})).unwrap(); + assert_eq!(shown["ids"], json!([doubled.0])); + s.execute("addon.cleanup.fix", &json!({"kinds": ["duplicateObjects"], "scope": "document"})).unwrap(); + assert!(node(&s, doubled).is_none()); + for id in [base, other, far] { + assert!(node(&s, id).is_some(), "{id:?} stays"); + } +} + #[test] fn fix_all_is_one_undo_step() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index 90ce4f6c3a..71ae5c02d0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5726,7 +5726,7 @@ envelopes, graphs, symbols), are left out. | `strayPoints` | Paths of one anchor (not clipping paths) | Removes them | | `unpainted` | Paths and compound paths with no fill and no stroke | Removes them | | `zeroOpacity` | Objects at 0 % opacity | Removes them | -| `duplicateObjects` | A path stacked directly on an identical one (geometry, paint, opacity) | Removes the upper one | +| `duplicateObjects` | A path stacked directly on an identical one (geometry, paint, opacity), or type stacked directly on the same type (text, styles, paint) within 1 pt of it | Removes the upper one | | `offArtboard` | Top-level objects that touch no artboard | Removes them | | `duplicateAnchors` | Neighbouring anchors within 0.01 pt | Merges them | | `tinyHandles` | Handles shorter than 0.01 pt | Retracts them | From 672a5342a617c1ffb6193b3a8a1dac5adf93764e Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Sat, 31 Oct 2026 19:19:55 +0700 Subject: [PATCH 83/98] Fix #4817: Properties' Quick Actions has Blend Options for a selected blend with any tool --- crates/ui-egui/src/panels/properties.rs | 8 +++++--- crates/ui-egui/src/panels/properties/tests.rs | 10 +++++----- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 5 +++-- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49716e0489..a10f5c1334 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -111,10 +111,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { section_header(ui, tl!("Quick Actions")); let is_group = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Group { .. })); let is_live = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Path { live: Some(l), .. }) if !matches!(**l, LiveShape::Path { .. })); - // Blend Options for one blend while the Blend tool is in use, Merge Live Paint for a Live - // Paint group selected with more to merge into it, as the Properties panel overview lists them + // Blend Options for one selected blend, Merge Live Paint for a Live Paint group selected with + // more to merge into it, as the Properties panel overview lists them // (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/properties-panel-overview.html>). - let blend_options = n_sel == 1 && app.session.tool_id() == "blend" && matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Blend { .. })); + // The overview names the Blend tool; the blending page has the button for a blend selected with + // any tool (<https://helpx.adobe.com/illustrator/using/blending-objects.html>, #4817). + let blend_options = n_sel == 1 && matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Blend { .. })); let merge_live_paint = n_sel > 1 && can_merge_live_paint(app); let mut actions: Vec<(&str, &str)> = vec![]; if n_sel > 1 { diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 7bf2e6d151..0bd879a318 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -1254,18 +1254,18 @@ fn rect(app: &mut VectorcraftApp, x: f64, y: f64, w: f64, h: f64) -> serde_json: app.run("shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].clone() } -/// One blend selected with the Blend tool has Blend Options in Quick Actions, which opens the -/// Blend Options dialog on it; OK is one undo step. Not with another tool, nor for a blend -/// selected with another object. +/// One selected blend has Blend Options in Quick Actions, with the Selection tool (#4817) as with +/// the Blend tool, which opens the Blend Options dialog on it; OK is one undo step. Not for a +/// blend selected with another object. #[test] -fn blend_options_is_a_quick_action_for_one_blend_with_the_blend_tool() { +fn blend_options_is_a_quick_action_for_one_blend() { let mut app = new_app(); let (a, b) = (rect(&mut app, 20.0, 20.0, 40.0, 40.0), rect(&mut app, 200.0, 20.0, 40.0, 40.0)); app.run("select.set", json!({"ids": [a, b]})).unwrap(); let blend = app.run("object.blend.make", json!({})).unwrap()["id"].clone(); app.run("select.set", json!({"ids": [blend]})).unwrap(); let ctx = egui::Context::default(); - assert!(!shows(&tall(&mut app, &ctx, vec![]), "Blend Options"), "not with the Selection tool"); + assert!(shows(&tall(&mut app, &ctx, vec![]), "Blend Options"), "with the Selection tool"); app.select_tool("blend"); app.run("select.set", json!({"ids": [blend]})).unwrap(); let texts = tall(&mut app, &ctx, vec![]); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 58e42441c6..8712f26f5f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -519,7 +519,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Names of new and renamed artboards, brushes and patterns:** `artboard.new` and `artboard.setProps` trim the white space around the name they are given, as the Artboards panel's rename does, and a blank name gives the artboard its default name, Artboard N for the Nth artboard. `brush.new`, `brush.duplicate`, `brush.options`, `object.pattern.make`, `object.pattern.saveCopy` and `pattern.options` trim the name as the Brush Options dialog and the Pattern Options panel do, and compare the trimmed name with the names there are; a blank name gives the name each command gives when there is none, and Brush Options and Pattern Options keep the name. Tests in `tests/panelcmds.rs`, `tests/brushsym.rs` and `tests/pattern.rs` cover each command. Public references: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html), [Brushes](https://web.archive.org/web/20251018101754/https://helpx.adobe.com/illustrator/using/brushes.html) and [Create and edit patterns](https://web.archive.org/web/20211031180358/https://helpx.adobe.com/illustrator/using/create-edit-patterns.html) (archived copies). - **Pattern names and swatch names:** `pattern.options` refuses a new name that another pattern, a swatch or a color group has. It takes a name that only the pattern's own swatches have. The reference page does not say what happens when the new name is taken; the refusal follows VectorCraft's rule that swatches and color groups share one namespace (`Document::swatch_name_taken`), which `swatch.new` and `swatch.edit` also follow. A rename gives the new name only to the pattern's swatches that had the old one; any other swatch with the old name keeps it, and art linked to that swatch stays linked. `object.pattern.make` and `object.pattern.saveCopy` number a color group's name as they number a swatch's. Tests in `tests/pattern.rs` cover each command. Public reference: [Create and edit patterns](https://web.archive.org/web/20211031180358/https://helpx.adobe.com/illustrator/using/create-edit-patterns.html) (archived copy). - **A clip group in Properties:** with one clip group selected, the panel has Edit Clipping Path and Edit Contents at its top, under the Clip Group heading, which select the clipping path or the clipped art for the selection tools to transform alone, as Object › Clipping Mask does (<https://www.adobe.com/learn/illustrator/web/crop-using-masks>: with a clip group selected, the tutorial uses Edit Clipping Path at the top of the Properties panel to select the mask for editing, and uses Edit Contents to select the masked art). Edit Contents shows only when the group clips something. Not checked against the reference app: whether Properties keeps the buttons while the clipping path or the contents is selected; the Control bar has no clip-group buttons here. Evidence: `panels::properties::tests::one_clip_group_has_edit_clipping_path_and_edit_contents_at_the_top`. -- **Contextual controls in Properties:** the [Properties panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/properties-panel-overview.html) lists controls that appear for particular selections and tools; Properties has them, each running the existing command. Quick Actions has Blend Options for one selected blend while the Blend tool is in use (the Blend Options dialog, OK one undo step) and Merge Live Paint when a Live Paint group is selected with more paths and nothing Live Paint refuses (`livePaint.merge`). Appearance has the stroke's Variable Width Profile list, the Control bar's, with the Width tool or when the stroke has a variable width (`stroke.set {profile}`), and beside fx the Delete icon: with one effect on the one selected object (its own or a fill's or stroke's) its name and an icon that removes it (`effect.remove`), with several an icon that opens the Appearance panel, as the overview describes. With Direct Selection and corners of one path selected, Corner and Corner Radius set the kind and radius of those corners (`object.setLiveShape`); a live rectangle's and polygon's own rows already do. Each edit is one undo step. The overview's Variable Font icon was already in Character. Not checked against the reference app: whether Merge needs two Live Paint groups (the overview speaks of several selected groups; `livePaint.merge` takes a group and paths), and where exactly each control sits in its section. Evidence: `panels::properties::tests::{blend_options_is_a_quick_action_for_one_blend_with_the_blend_tool, merge_live_paint_is_a_quick_action_for_a_live_paint_group_and_more, the_width_profile_shows_for_a_variable_width_or_the_width_tool, direct_selected_corners_show_their_kind_and_radius, one_effect_has_a_delete_icon_in_appearance}`. +- **Contextual controls in Properties:** the [Properties panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/properties-panel-overview.html) lists controls that appear for particular selections and tools; Properties has them, each running the existing command. Quick Actions has Blend Options for one selected blend, with the Blend tool or any other, as the [blending page](https://helpx.adobe.com/illustrator/using/blending-objects.html) describes (#4817) (the Blend Options dialog, OK one undo step) and Merge Live Paint when a Live Paint group is selected with more paths and nothing Live Paint refuses (`livePaint.merge`). Appearance has the stroke's Variable Width Profile list, the Control bar's, with the Width tool or when the stroke has a variable width (`stroke.set {profile}`), and beside fx the Delete icon: with one effect on the one selected object (its own or a fill's or stroke's) its name and an icon that removes it (`effect.remove`), with several an icon that opens the Appearance panel, as the overview describes. With Direct Selection and corners of one path selected, Corner and Corner Radius set the kind and radius of those corners (`object.setLiveShape`); a live rectangle's and polygon's own rows already do. Each edit is one undo step. The overview's Variable Font icon was already in Character. Not checked against the reference app: whether Merge needs two Live Paint groups (the overview speaks of several selected groups; `livePaint.merge` takes a group and paths), and where exactly each control sits in its section. Evidence: `panels::properties::tests::{blend_options_is_a_quick_action_for_one_blend_with_the_blend_tool, merge_live_paint_is_a_quick_action_for_a_live_paint_group_and_more, the_width_profile_shows_for_a_variable_width_or_the_width_tool, direct_selected_corners_show_their_kind_and_radius, one_effect_has_a_delete_icon_in_appearance}`. - **Edit Artboards in Properties:** the active artboard's name is a field. Typing a new name and pressing Enter or clicking elsewhere renames that artboard in one undo step, as the Artboards panel's rename does (`artboard.setProps {index, name}`); pressing Escape or leaving the field empty keeps the old name. A click on the Artboard Options icon beside the field opens Artboard Options for the active artboard, as double-clicking the Artboard tool's button does (`tool.options {tool: "artboard"}`). Pointer and key tests in `panels/properties.rs` cover both. Public references: [Resize artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/resize-artboards.html) and [Create and add new artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/add-new-artboards.html). - **Artboard background colors:** each artboard can have a background color (`artboard.setProps {index, color}`, `color: null` removes it). With the Artboard tool, Properties has a Fill row: its list sets None, White or Black, and its chip and its Color Picker… item open the Color Picker for the artboard. Artboard Options has a Background Color row with a list that sets None, White or Black, and a color button. The canvas draws the artboard's paper in the color, multiplied with the paper color while Simulate Colored Paper is on, and the transparency grid still covers it. The native format saves the color, and a document without colors saves as before. Exports and print do not draw the color. Engine, canvas, Properties, Color Picker and Artboard Options tests cover it. Public reference: [Apply colors to artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/add-background-color-to-artboards.html). - **Resizing several artboards:** with several artboards selected with the Artboard tool, a new W or H in Properties, the Transform panel or the Control bar, a preset or an orientation in Properties or the Control bar, and a new Width or Height in Artboard Options resize every selected artboard in one undo step (`artboard.setProps {artboards}`); with Scale Artwork with Artboard on, Properties, the Transform panel and the Control bar scale each object once, with the artboard it was inside before the change. The Transform panel, the Control bar and Artboard Options resize each one about its own reference point, a preset sizes each one in its own orientation, and X and Y still move the active artboard alone. Pointer and key tests in `panels/properties.rs`, dialog tests in `dialogs/tests.rs` and an engine test in `tests/xform.rs` cover them. Public references: [Artboard Enhancements In Illustrator CC](https://blog.adobe.com/en/publish/2017/11/10/artboard-enhancements-in-illustrator-cc) (a height and a width entered once resize every selected artboard) and [Using multiple artboards](https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (2019). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 73e52f87a7..f32db08429 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Direct Selection snaps a dragged line end square to straight paths and grazing curves; Path Precision Tools add-on: Shared Edge Merge Tool; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Properties: Blend Options in Quick Actions for one selected blend with any tool, #4817; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -293,7 +293,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Toolbar without group labels by default, one or two columns; All Tools drawer by flyout group with shortcuts, its menu (Reset, Basic, Advanced) and Show section | done | #1751; a preference brings the labels back; `toolbar.show` sets the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls | | Actions panel: a clicked action stays selected for Play Current Selection and Delete Selection | partial | #1853 (part of #1629); editing actions and action shortcuts remain | | Actions panel: Create New Set (bottom bar, ≡ › New Set…) names a new set and Set Options… renames the current one; a click on a set's name makes it current and a recording goes into the current set ([helpx: Automation with actions](https://helpx.adobe.com/illustrator/using/automation-actions.html)) | partial | #3712; the New Action dialog (name, set menu, shortcut, colour), dragging actions between sets and deleting a set remain | -| Properties panel per context (every selection type's Quick Actions) | partial | one clip group: Edit Clipping Path and Edit Contents, at the top of the panel rather than in Quick Actions (G1); the overview's additional controls ([Properties panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/properties-panel-overview.html)): Blend Options (one blend, Blend tool) and Merge Live Paint (a Live Paint group with more) in Quick Actions, the Width Profile list (Width tool or a variable-width stroke) and an applied effect's Delete icon in Appearance, Corner and Corner Radius for corners selected with Direct Selection; Variable Font was already there | +| Properties panel per context (every selection type's Quick Actions) | partial | one clip group: Edit Clipping Path and Edit Contents, at the top of the panel rather than in Quick Actions (G1); the overview's additional controls ([Properties panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/properties-panel-overview.html)): Blend Options (one blend selected, with any tool, #4817) and Merge Live Paint (a Live Paint group with more) in Quick Actions, the Width Profile list (Width tool or a variable-width stroke) and an applied effect's Delete icon in Appearance, Corner and Corner Radius for corners selected with Direct Selection; Variable Font was already there | | Select › Start Global Edit / Stop Global Edit; Properties › Quick Actions with the Global Edit Options popover (Match: Appearance, Size; On Artboards: All Artboards or a range, Include Objects On Canvas; Result(s)); Escape stops it; the matched objects are highlighted in their layer colour while it runs | partial | #3521, `select.globalEdit` / `select.globalEditOptions`, `crates/engine/src/tests/globaledit.rs`; paths, compound paths and symbol instances; the Control panel's copy of the options, Shift-click to leave a match out, highlighting matches before the start and similar groups remain ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)) | | Contextual task bar | done | | | Control bar with Transform and Stroke popovers | done | | @@ -376,6 +376,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Properties: Quick Actions has Blend Options for one selected blend with any tool, not only the Blend tool, as the blending page describes (#4817); scores unchanged | | 2026-10-31 | minor | Smart guides and snapping: Snap to Perpendicular and Snap to Tangent for an existing line's end dragged with Direct Selection (#2530), so the tangent and perpendicular row is done; scores and estimates unchanged | | 2026-10-31 | minor | Grid Repeat Options has a Preview checkbox (#2651); scores and estimates unchanged | | 2026-10-31 | minor | Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together; scores and estimates unchanged | From b0f6174c37aaf1262bbb90bd5c94cac08801a044 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 31 Oct 2026 04:35:41 -0700 Subject: [PATCH 84/98] i18n: Dutch names a brush 'penseel' everywhere, with neuter agreement --- crates/ui-egui/src/i18n/nl.tsv | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 6078c314c9..d7a608c311 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2836,32 +2836,32 @@ theme Appearance Vormgeving Remove Asset Asset verwijderen Rename Asset Asset hernoemen Fill Rule Vulregel - Apply Brush Kwast toepassen + Apply Brush Penseel toepassen Break Link to Symbol Koppeling met symbool verbreken - Brush Definition Kwastdefinitie - Brush Options… Kwastopties… - Current Brush Huidige kwast - Current Symbol Huidige symbool - Delete Brush Kwast verwijderen + Brush Definition Penseeldefinitie + Brush Options… Penseelopties… + Current Brush Huidig penseel + Current Symbol Huidig symbool + Delete Brush Penseel verwijderen Delete Symbol Symbool verwijderen - Duplicate Brush Kwast dupliceren + Duplicate Brush Penseel dupliceren Duplicate Symbol Symbool dupliceren Edit Symbol Symbool bewerken - Expand Brush Strokes Kwaststreken omzetten - New Brush… Nieuwe kwast… - New Brush Nieuwe kwast + Expand Brush Strokes Penseelstreken omzetten + New Brush… Nieuw penseel… + New Brush Nieuw penseel Calligraphic Brush Kalligrafisch penseel Scatter Brush Verspreidingspenseel Art Brush Artpenseel Bristle Brush Borstelpenseel Pattern Brush Patroonpenseel Select art to make a scatter, art or pattern brush. Selecteer illustratie om een verspreidings-, art- of patroonpenseel te maken. - Brush Art Kwastillustratie + Brush Art Penseelillustratie New Symbol Nieuw symbool New Symbol… Nieuw symbool… Place Symbol Instance Symboolexemplaar plaatsen Redefine Symbol Symbool opnieuw definiëren - Remove Brush Stroke Kwaststreek verwijderen + Remove Brush Stroke Penseelstreek verwijderen Replace Symbol Symbool vervangen Select All Instances Alle exemplaren selecteren Symbol Sprayer Symboolspuit @@ -2937,7 +2937,7 @@ theme Appearance Vormgeving Saved Selections Opgeslagen selecties Select Guides Hulplijnen selecteren Move Guide to Layer Hulplijn naar laag verplaatsen - Blob Brush BLOB-kwast + Blob Brush Blob-penseel Convert Anchor Point Ankerpunt omzetten Curvature Kromming Delete Anchor Point Ankerpunt verwijderen @@ -3420,7 +3420,7 @@ theme Appearance Vormgeving Delete Swatch Kleurstaal verwijderen Duplicate Swatch Kleurstaal dupliceren Merge Swatches Kleurstalen samenvoegen - Move Brush Kwast verplaatsen + Move Brush Penseel verplaatsen Move Swatch Kleurstaal verplaatsen New Color Group Nieuwe kleurgroep New Swatch Nieuw kleurstaal From c0872ff9c6c02a32a5dcdee62ee8814f5de3bef5 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:40:51 +0000 Subject: [PATCH 85/98] Fix enhanced_preset Shapes for decorated High Fidelity Photo names Found while testing crates/trace/src/params.rs:248-251 (enhanced_preset) Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- crates/trace/src/params.rs | 2 +- crates/trace/tests/enhanced_preset_name.rs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 crates/trace/tests/enhanced_preset_name.rs diff --git a/crates/trace/src/params.rs b/crates/trace/src/params.rs index 6ce6ce2415..dd5bc99298 100644 --- a/crates/trace/src/params.rs +++ b/crates/trace/src/params.rs @@ -247,5 +247,5 @@ pub fn presets() -> Vec<(&'static str, TraceParams)> { /// Gradients stay an option to turn on. Flat Logo, a preset of our own, has none. pub fn enhanced_preset(name: &str) -> Option<TraceParams> { let p = preset(name).filter(|p| p.mode != Mode::Logo)?; - Some(TraceParams { auto_grouping: true, shapes: !name.eq_ignore_ascii_case("High Fidelity Photo"), ..p }) + Some(TraceParams { auto_grouping: true, shapes: p != preset("High Fidelity Photo")?, ..p }) } diff --git a/crates/trace/tests/enhanced_preset_name.rs b/crates/trace/tests/enhanced_preset_name.rs new file mode 100644 index 0000000000..b84bb6e6a6 --- /dev/null +++ b/crates/trace/tests/enhanced_preset_name.rs @@ -0,0 +1,13 @@ +//! `enhanced_preset` resolves a name the way `preset` does (trimmed, brackets stripped, any case), +//! so Shapes is off for High Fidelity Photo however its name is written. + +use vectorcraft_trace::enhanced_preset; + +#[test] +fn enhanced_high_fidelity_photo_ignores_name_decoration() { + let plain = enhanced_preset("High Fidelity Photo").unwrap(); + assert!(!plain.shapes); + for name in ["[High Fidelity Photo]", " High Fidelity Photo ", " high fidelity photo "] { + assert_eq!(enhanced_preset(name), Some(plain.clone()), "{name:?}"); + } +} From 6664e366e7a62851e66f436b7d346f66a00c4f90 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:03:57 +0100 Subject: [PATCH 86/98] Reject repeated '#' in perspective grid colour parser Found while testing crates/tools/src/distort/perspective/define.rs:84-86 (Rgb::parse) Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- crates/tools/src/distort/perspective/define.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/tools/src/distort/perspective/define.rs b/crates/tools/src/distort/perspective/define.rs index a2dd6f4249..19600bac41 100644 --- a/crates/tools/src/distort/perspective/define.rs +++ b/crates/tools/src/distort/perspective/define.rs @@ -82,7 +82,8 @@ pub struct Rgb(pub [u8; 3]); impl Rgb { /// `#rrggbb` or `rrggbb`. pub fn parse(s: &str) -> Option<Self> { - let h = s.trim().trim_start_matches('#'); + let h = s.trim(); + let h = h.strip_prefix('#').unwrap_or(h); if h.len() != 6 || !h.chars().all(|c| c.is_ascii_hexdigit()) { return None; } @@ -397,6 +398,15 @@ mod tests { const AB: Rect = Rect::new(0.0, 0.0, 800.0, 600.0); + #[test] + fn rgb_rejects_repeated_hash() { + assert!(Rgb::parse("##ff0000").is_none()); + assert!(Rgb::parse("####00ff00").is_none()); + assert!(Rgb::try_from(String::from("##ff0000")).is_err()); + assert_eq!(Rgb::parse("#ff0000"), Some(Rgb([255, 0, 0]))); + assert_eq!(Rgb::parse("ff0000"), Some(Rgb([255, 0, 0]))); + } + #[test] fn the_station_point_places_the_vanishing_points() { let mut g = PerspectiveGrid::normal(2, AB); From aaeb1b2b35c43d10c92cbb99a604f97821e57854 Mon Sep 17 00:00:00 2001 From: Shunsuke Yoshizawa <63687325+yssy1982@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:37:32 +0900 Subject: [PATCH 87/98] Respect SVG image-rendering pixelated attribute --- crates/svg/src/import/importer.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/svg/src/import/importer.rs b/crates/svg/src/import/importer.rs index 2215458e8c..c2220f9d8f 100644 --- a/crates/svg/src/import/importer.rs +++ b/crates/svg/src/import/importer.rs @@ -652,10 +652,16 @@ impl Importer { if link.is_some() && !missing { blob.with_proxy() } else { blob } }); let xf = acc * Affine::scale_non_uniform(size.width() as f64 / pw as f64, size.height() as f64 / ph as f64); - self.named( + let mut node = self.named( i.id(), NodeKind::Image(Box::new(ImageObject { key, width: pw, height: ph, xf, link, placement: Default::default(), pixels: None })), - ) + )?; + + if i.rendering_mode() == usvg::ImageRendering::Pixelated { + node.extras_mut().pixelated = true; + } + + Some(node) } /// An SVG shown as an image (a data URL or a linked file) → its art, `acc` mapping its size. From 40dd6db6e1420830ea4fe23b6c44b7c23053747e Mon Sep 17 00:00:00 2001 From: marshiyar <57055687+marshiyar@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:04:01 -0700 Subject: [PATCH 88/98] contributors: add names for marshiyar --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 0134ded6cb..55b5b38609 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -180,3 +180,8 @@ real_name = "Ashok Ram" [people.dylanmtaylor] sync_github_name = true + +[people.marshiyar] +display_name = "Arshiya" +real_name = "Arshiya Rahgozar" +sync_github_name = false From 54b0c7266c022dbec07a1b428d8755302111a3f3 Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Sat, 31 Oct 2026 20:28:52 +0700 Subject: [PATCH 89/98] Fix #4826: Artwork Cleanup's Join Point Text stacks one-line type into one object --- crates/engine/src/cmd/addons/cleanup/text.rs | 90 ++++++++++++++++++- .../src/cmd/addons/cleanup/text/tests.rs | 34 +++++++ crates/engine/tests/command_sweep.rs | 2 + crates/ui-egui/src/addons/cleanup.rs | 4 +- crates/ui-egui/src/addons/cleanup/tests.rs | 1 + crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + docs/mcp.md | 5 ++ docs/target-app-parity.md | 6 +- 32 files changed, 188 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index 26a37efbf1..dd7cb67888 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -1,5 +1,5 @@ //! Artwork Cleanup's text tools: Break Text into Lines / Paragraphs / Words / Glyphs, Combine Point -//! Text, Split Text at Cursor, Remove Line / Paragraph Breaks, Set Alignment, Keep Position, Reset +//! Text, Join Point Text, Split Text at Cursor, Remove Line / Paragraph Breaks, Set Alignment, Keep Position, Reset //! Text Transform and Replace Missing Fonts. Each is one undo step. Pieces keep their characters' //! styles and stay where the layout drew them: each is moved so the glyph of its first character //! lands where it was. @@ -57,6 +57,15 @@ pub fn specs() -> Vec<CommandSpec> { has_selection, combine_text ), + cmd!( + "addon.cleanup.joinText", + "Join Point Text", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{ids?: [id…] (default: the selection)} join one-line horizontal point type into one point type object, as one undo step: pieces turned and sheared like the top-left one are read as lines (baselines within 0.4 em of each other), top to bottom, each line's pieces left to right with a space between them; each line becomes a paragraph, left aligned, and keeps the distance between baselines as its leading. The text keeps its character styles; the object takes the top-left piece's place and paint, and the other pieces go → {id (selected), joined (pieces merged into it), lines}", + has_selection, + join_text + ), cmd!( "addon.cleanup.removeBreaks", "Remove Breaks", @@ -641,6 +650,85 @@ fn combine_text(s: &mut Session, p: &Value) -> Result<Value> { Ok(json!({ "ids": ids, "combined": combined })) } +fn join_text(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "addon.cleanup.joinText"; + let d = &s.doc()?.doc; + let pieces: Vec<Piece> = + texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().filter_map(|id| Piece::of(d, id).map(|p| p.0)).collect(); + // The piece top-left on the page sets the turn; the others turned like it are read in that frame. + let on_page = |p: &Piece| Affine::new([p.turn[0], p.turn[1], p.turn[2], p.turn[3], 0.0, 0.0]) * p.at; + let first = pieces.iter().min_by(|x, y| { + let (a, b) = (on_page(x), on_page(y)); + a.y.total_cmp(&b.y).then(a.x.total_cmp(&b.x)) + }); + let mut kept: Vec<&Piece> = first.map(|f| pieces.iter().filter(|p| p.turned_like(f)).collect()).unwrap_or_default(); + kept.sort_by(|x, y| x.at.y.total_cmp(&y.at.y).then(x.at.x.total_cmp(&y.at.x))); + // Lines, top to bottom, by baseline; each line's pieces left to right. + let mut lines: Vec<Vec<&Piece>> = vec![]; + for piece in kept { + match lines.last_mut().filter(|l| l.first().is_some_and(|f| (f.at.y - piece.at.y).abs() <= SAME_LINE * f.size.min(piece.size))) { + Some(l) => l.push(piece), + None => lines.push(vec![piece]), + } + } + for line in &mut lines { + line.sort_by(|x, y| x.at.x.total_cmp(&y.at.x)); + } + let joined: usize = lines.iter().map(Vec::len).sum(); + let (Some(top), true) = (lines.first().and_then(|l| l.first()).copied(), joined >= 2) else { + return Err(EngineError::Other("Select two or more one-line point type objects to join".into())); + }; + let mut runs: Vec<vectorcraft_doc::TextRun> = vec![]; + let mut prev_y = None; + for line in &lines { + let y = line.first().map_or(top.at.y, |p| p.at.y); + // The line keeps the distance from the baseline above as its leading. + let leading = prev_y.map(|above| y - above).filter(|l: &f64| l.is_finite() && *l > 0.0); + prev_y = Some(y); + let last = |runs: &[vectorcraft_doc::TextRun]| runs.iter().rev().find(|r| !r.text.is_empty()).map(|r| r.style.clone()).unwrap_or_default(); + if !runs.is_empty() { + runs.push(vectorcraft_doc::TextRun::new("\n", last(&runs))); + } + let start = runs.len(); + for (k, piece) in line.iter().enumerate() { + let blank = runs.last().is_some_and(|r| r.text.ends_with(char::is_whitespace)) || piece.t.plain_text().starts_with(char::is_whitespace); + if k > 0 && !blank { + runs.push(vectorcraft_doc::TextRun::new(" ", last(&runs))); + } + runs.extend(piece.t.runs.iter().cloned()); + } + if let Some(l) = leading { + for r in runs.iter_mut().skip(start) { + r.style.leading = Some(l); + } + } + } + let mut m = top.t.clone(); + m.set_all_paras(ParaStyle { justify: Justify::Left, ..top.t.para.clone() }); + m.runs = runs; + edit::normalize(&mut m.runs); + m.ruby.clear(); + // The top-left piece's first glyph stays where it was. + let [a, b, c, dd] = top.turn; + let base = Affine::new([a, b, c, dd, 0.0, 0.0]) * Affine::translate(top.at.to_vec2()); + m.xf = base; + let x0 = glyph_starting(&layout(&m), 0).map_or(0.0, |g| g.x); + m.xf = base * Affine::translate((-x0, 0.0)); + refresh_bounds(&mut m); + let id = top.id; + let gone: Vec<NodeId> = lines.iter().flatten().map(|p| p.id).filter(|g| *g != id).collect(); + let count = lines.len(); + s.edit("Join Point Text", |d, sel| { + if let Some(n) = d.node_mut(id) { + n.kind = NodeKind::Text(Box::new(m)); + } + d.remove_many(&gone)?; + sel.set([id]); + Ok(()) + })?; + Ok(json!({ "id": id.0, "joined": joined, "lines": count })) +} + /// Remove the breaks `brk` in bytes `a..b` of `t`: one between two words becomes a space → how many /// went. fn strip_breaks(t: &mut TextObject, a: usize, b: usize, brk: char) -> usize { diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs index a2fa3225f0..2286288662 100644 --- a/crates/engine/src/cmd/addons/cleanup/text/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -176,6 +176,40 @@ fn combine_joins_only_pieces_on_one_line_turned_alike() { assert!(other(s.execute("addon.cleanup.combineText", &json!({}))).contains("combine")); } +/// Join Point Text makes one object of stacked one-line pieces: lines top to bottom as paragraphs, +/// pieces on a line left to right, each line's baseline where it was (its leading is the distance +/// from the line above), in one undo step. A piece turned unlike the top-left one stays. +#[test] +fn join_stacks_lines_into_one_object_with_their_spacing() { + let mut s = session(); + let a = text(&mut s, json!({"x": 50, "y": 100, "text": "First", "size": 20})); + let b = text(&mut s, json!({"x": 120, "y": 101, "text": "line", "size": 20})); + let c = text(&mut s, json!({"x": 50, "y": 130, "text": "Second", "size": 20})); + let d = text(&mut s, json!({"x": 50, "y": 166, "text": "Third", "size": 20})); + let turned = text(&mut s, json!({"x": 300, "y": 300, "text": "Turned", "size": 20, "rotation": 45})); + let was = glyphs(&s, &[a, c, d]); + select(&mut s, &[d, b, turned, c, a]); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.joinText", &json!({})).unwrap(); + assert_eq!(undo_steps(&s), steps + 1); + assert_eq!((r["id"].as_u64(), r["joined"].as_u64(), r["lines"].as_u64()), (Some(a.0), Some(4), Some(3))); + let t = obj(&s, a); + assert_eq!(t.plain_text(), "First line\nSecond\nThird"); + assert_eq!(t.runs.iter().filter(|r| r.text.contains("Third")).map(|r| r.style.leading).collect::<Vec<_>>(), [Some(36.0)]); + // Each line's first glyph is where it was. + let now = glyphs(&s, &[a]); + for ch in ['F', 'S', 'T'] { + let (p, q) = (was.iter().find(|g| g.0 == ch).unwrap().1, now.iter().find(|g| g.0 == ch).unwrap().1); + assert!((p - q).hypot() < 0.05, "{ch}: {p:?} moved to {q:?}"); + } + let doc = &s.doc().unwrap().doc; + assert!(doc.node(b).is_none() && doc.node(c).is_none() && doc.node(d).is_none() && doc.node(turned).is_some()); + select(&mut s, &[turned]); + assert!(other(s.execute("addon.cleanup.joinText", &json!({}))).contains("join")); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(c).is_some(), "undo brings the pieces back"); +} + #[test] fn remove_line_and_paragraph_breaks() { let mut s = session(); diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 27c6088318..e80e93ce8a 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -601,6 +601,8 @@ fn structured_junk() { ("addon.cleanup.breakText", json!({"by": "words", "ids": "all"})), ("addon.cleanup.combineText", json!({"ids": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]})), ("addon.cleanup.combineText", json!({"ids": [-1, 1e308]})), + ("addon.cleanup.joinText", json!({"ids": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]})), + ("addon.cleanup.joinText", json!({"ids": [-1, 1e308, "x"]})), ("addon.cleanup.removeBreaks", json!({"kind": "paragraph", "ids": [1, 2, 3, 4, 5, 6, 7, 8, u64::MAX]})), ("addon.cleanup.removeBreaks", json!({"kind": 3})), ("addon.cleanup.removeBreaks", json!({"kind": "line", "ids": null})), diff --git a/crates/ui-egui/src/addons/cleanup.rs b/crates/ui-egui/src/addons/cleanup.rs index d44fe7b393..cf5dbbfd8a 100644 --- a/crates/ui-egui/src/addons/cleanup.rs +++ b/crates/ui-egui/src/addons/cleanup.rs @@ -38,7 +38,7 @@ fn replace_fonts() -> Value { /// modifier click does, command, params). type TextRow = (Option<&'static str>, Vec<(&'static str, Option<&'static str>, &'static str, Value)>); -fn text_rows() -> [TextRow; 4] { +fn text_rows() -> [TextRow; 5] { let align = |a: &str| json!({ "align": a }); let by = |b: &str| json!({ "by": b }); [ @@ -66,6 +66,7 @@ fn text_rows() -> [TextRow; 4] { ], ), (None, vec![("Combine Point Text", None, "addon.cleanup.combineText", json!({}))]), + (None, vec![("Join Point Text", None, "addon.cleanup.joinText", json!({}))]), ] } @@ -98,6 +99,7 @@ pub fn menu() -> Vec<Item> { let align = |a: &str| json!({ "align": a }); items.extend([ Item::Cmd("Combine Point Text", "addon.cleanup.combineText", json!({})), + Item::Cmd("Join Point Text", "addon.cleanup.joinText", json!({})), Item::Cmd("Break Text into Lines", "addon.cleanup.breakText", by("lines")), Item::Cmd("Break Text into Paragraphs", "addon.cleanup.breakText", by("paragraphs")), Item::Cmd("Break Text into Words", "addon.cleanup.breakText", by("words")), diff --git a/crates/ui-egui/src/addons/cleanup/tests.rs b/crates/ui-egui/src/addons/cleanup/tests.rs index edf7159241..f6b3f820ef 100644 --- a/crates/ui-egui/src/addons/cleanup/tests.rs +++ b/crates/ui-egui/src/addons/cleanup/tests.rs @@ -95,6 +95,7 @@ fn the_plugins_submenu_runs_the_text_tools() { let cleanup = ["Object", "Plug-ins", "Artwork Cleanup"]; for (label, id) in [ ("Combine Point Text", "addon.cleanup.combineText"), + ("Join Point Text", "addon.cleanup.joinText"), ("Break Text into Lines", "addon.cleanup.breakText"), ("Break Text into Paragraphs", "addon.cleanup.breakText"), ("Break Text into Glyphs", "addon.cleanup.breakText"), diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 706c4a5f65..2cf931a8aa 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5800,6 +5800,7 @@ anchor Points: Точки: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text Разделяне на текст Combine Point Text Обединяване на точков текст + Join Point Text Съединяване на точков текст Remove Breaks Премахване на прекъсвания Split Text at Cursor Разделяне на текст при курсора Set Alignment, Keep Position Задаване на подравняване със запазване на позицията @@ -5828,6 +5829,7 @@ breakText Glyphs Глифи @msg Select horizontal point or area type whose text fits Изберете хоризонтален точков текст или текст в зона, чийто текст се побира @msg Nothing to split at the cursor При курсора няма какво да се раздели @msg Select two or more one-line point type objects on one baseline to combine Изберете два или повече едноредови обекта с точков текст на една базова линия, за да ги обедините +@msg Select two or more one-line point type objects to join Изберете два или повече едноредови обекта с точков текст, за да ги съедините @msg No line breaks to remove Няма прекъсвания на редове за премахване @msg No paragraph breaks to remove Няма прекъсвания на абзаци за премахване @msg Too many breaks to remove at once Твърде много прекъсвания за премахване наведнъж diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 597eb81828..a9adf22ae1 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5901,6 +5901,7 @@ anchor Points: Punts: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text Divideix el text Combine Point Text Combina el text de punt + Join Point Text Uneix el text de punt Remove Breaks Elimina els salts Split Text at Cursor Divideix el text al cursor Set Alignment, Keep Position Defineix l’alineació i manté la posició @@ -5929,6 +5930,7 @@ breakText Glyphs Glifs @msg Select horizontal point or area type whose text fits Seleccioneu text de punt o d’àrea horitzontal el text del qual hi càpiga @msg Nothing to split at the cursor No hi ha res a dividir al cursor @msg Select two or more one-line point type objects on one baseline to combine Seleccioneu dos o més objectes de text de punt d’una línia sobre una mateixa línia de base per combinar-los +@msg Select two or more one-line point type objects to join Seleccioneu dos o més objectes de text de punt d’una línia per unir-los @msg No line breaks to remove No hi ha salts de línia per eliminar @msg No paragraph breaks to remove No hi ha salts de paràgraf per eliminar @msg Too many breaks to remove at once Massa salts per eliminar-los alhora diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index b1539f4d85..41d9b25f8f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5910,6 +5910,7 @@ anchor Points: Body: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text Rozdělit text Combine Point Text Sloučit bodový text + Join Point Text Spojit bodový text Remove Breaks Odstranit zalomení Split Text at Cursor Rozdělit text u kurzoru Set Alignment, Keep Position Nastavit zarovnání, zachovat polohu @@ -5938,6 +5939,7 @@ breakText Glyphs Glyfy @msg Select horizontal point or area type whose text fits Vyberte vodorovný bodový text nebo text v oblasti, jehož text se vejde @msg Nothing to split at the cursor U kurzoru není co rozdělit @msg Select two or more one-line point type objects on one baseline to combine Vyberte dva nebo více jednořádkových objektů bodového textu na jednom účaří, abyste je sloučili +@msg Select two or more one-line point type objects to join Vyberte dva nebo více jednořádkových objektů bodového textu, abyste je spojili @msg No line breaks to remove Žádná zalomení řádků k odstranění @msg No paragraph breaks to remove Žádná zalomení odstavců k odstranění @msg Too many breaks to remove at once Příliš mnoho zalomení k odstranění najednou diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index e1f760e1f0..1b6277fd59 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5815,6 +5815,7 @@ anchor Points: Punkter: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text Opdel tekst Combine Point Text Kombiner punkttekst + Join Point Text Sammenføj punkttekst Remove Breaks Fjern skift Split Text at Cursor Opdel tekst ved markøren Set Alignment, Keep Position Angiv justering, bevar placering @@ -5843,6 +5844,7 @@ breakText Glyphs Glyffer @msg Select horizontal point or area type whose text fits Vælg vandret punkt- eller områdetekst, hvis tekst kan være der @msg Nothing to split at the cursor Intet at opdele ved markøren @msg Select two or more one-line point type objects on one baseline to combine Vælg to eller flere punkttekstobjekter på én linje på samme grundlinje for at kombinere dem +@msg Select two or more one-line point type objects to join Vælg to eller flere punkttekstobjekter på én linje for at sammenføje dem @msg No line breaks to remove Ingen linjeskift at fjerne @msg No paragraph breaks to remove Ingen afsnitsskift at fjerne @msg Too many breaks to remove at once For mange skift til at fjerne på én gang diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index bcfc78c073..405462042d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5495,6 +5495,7 @@ anchor Points: Punkte: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text Text aufteilen Combine Point Text Punkttext zusammenfügen + Join Point Text Punkttext verbinden Remove Breaks Umbrüche entfernen Split Text at Cursor Text an der Einfügemarke teilen Set Alignment, Keep Position Ausrichtung festlegen, Position beibehalten @@ -5523,6 +5524,7 @@ breakText Glyphs Glyphen @msg Select horizontal point or area type whose text fits Wählen Sie horizontalen Punkt- oder Flächentext aus, dessen Text hineinpasst @msg Nothing to split at the cursor An der Einfügemarke gibt es nichts zu teilen @msg Select two or more one-line point type objects on one baseline to combine Wählen Sie zwei oder mehr einzeilige Punkttextobjekte auf einer Grundlinie aus, um sie zusammenzufügen +@msg Select two or more one-line point type objects to join Wählen Sie zwei oder mehr einzeilige Punkttextobjekte aus, um sie zu verbinden @msg No line breaks to remove Keine Zeilenumbrüche zu entfernen @msg No paragraph breaks to remove Keine Absatzumbrüche zu entfernen @msg Too many breaks to remove at once Zu viele Umbrüche, um sie auf einmal zu entfernen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8305782fa4..5147b4fd94 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5862,6 +5862,7 @@ anchor Points: Σημεία: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text Διαίρεση κειμένου Combine Point Text Συνδυασμός κειμένου σημείου + Join Point Text Ένωση κειμένου σημείου Remove Breaks Αφαίρεση αλλαγών Split Text at Cursor Διαίρεση κειμένου στον δρομέα Set Alignment, Keep Position Ορισμός στοίχισης με διατήρηση θέσης @@ -5890,6 +5891,7 @@ breakText Glyphs Γλύφοι @msg Select horizontal point or area type whose text fits Επιλέξτε οριζόντιο κείμενο σημείου ή περιοχής του οποίου το κείμενο χωράει @msg Nothing to split at the cursor Δεν υπάρχει τίποτα για διαίρεση στον δρομέα @msg Select two or more one-line point type objects on one baseline to combine Επιλέξτε δύο ή περισσότερα μονογραμμικά αντικείμενα κειμένου σημείου στην ίδια γραμμή βάσης για να τα συνδυάσετε +@msg Select two or more one-line point type objects to join Επιλέξτε δύο ή περισσότερα μονογραμμικά αντικείμενα κειμένου σημείου για να τα ενώσετε @msg No line breaks to remove Δεν υπάρχουν αλλαγές γραμμής για αφαίρεση @msg No paragraph breaks to remove Δεν υπάρχουν αλλαγές παραγράφου για αφαίρεση @msg Too many breaks to remove at once Πάρα πολλές αλλαγές για αφαίρεση ταυτόχρονα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index cd071b0ae1..bbd74c412b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5853,6 +5853,7 @@ anchor Points: Puntos: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text Dividir texto Combine Point Text Combinar texto de punto + Join Point Text Unir texto de punto Remove Breaks Quitar saltos Split Text at Cursor Dividir texto en el cursor Set Alignment, Keep Position Definir alineación manteniendo la posición @@ -5881,6 +5882,7 @@ breakText Glyphs Glifos @msg Select horizontal point or area type whose text fits Seleccione texto de punto o de área horizontal cuyo texto quepa @msg Nothing to split at the cursor No hay nada que dividir en el cursor @msg Select two or more one-line point type objects on one baseline to combine Seleccione dos o más objetos de texto de punto de una línea sobre la misma línea base para combinarlos +@msg Select two or more one-line point type objects to join Seleccione dos o más objetos de texto de punto de una línea para unirlos @msg No line breaks to remove No hay saltos de línea que quitar @msg No paragraph breaks to remove No hay saltos de párrafo que quitar @msg Too many breaks to remove at once Demasiados saltos para quitarlos a la vez diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1d34418d98..27c96b5301 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5941,6 +5941,7 @@ anchor Points: Pisteet: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text Jaa teksti Combine Point Text Yhdistä pisteteksti + Join Point Text Liitä pisteteksti Remove Breaks Poista vaihdot Split Text at Cursor Jaa teksti kohdistimen kohdalta Set Alignment, Keep Position Aseta tasaus, säilytä sijainti @@ -5969,6 +5970,7 @@ breakText Glyphs Merkit @msg Select horizontal point or area type whose text fits Valitse vaakasuora piste- tai alueteksti, jonka teksti mahtuu @msg Nothing to split at the cursor Kohdistimen kohdalla ei ole jaettavaa @msg Select two or more one-line point type objects on one baseline to combine Valitse kaksi tai useampia yksirivisiä pistetekstiobjekteja samalla perusviivalla yhdistääksesi ne +@msg Select two or more one-line point type objects to join Valitse kaksi tai useampia yksirivisiä pistetekstiobjekteja liittääksesi ne @msg No line breaks to remove Ei poistettavia rivinvaihtoja @msg No paragraph breaks to remove Ei poistettavia kappaleenvaihtoja @msg Too many breaks to remove at once Liian monta vaihtoa poistettavaksi kerralla diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5e1c40015e..bbcab564e8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5909,6 +5909,7 @@ anchor Points: Points : # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text Diviser le texte Combine Point Text Combiner le texte de point + Join Point Text Joindre le texte de point Remove Breaks Supprimer les sauts Split Text at Cursor Diviser le texte au curseur Set Alignment, Keep Position Définir l’alignement en conservant la position @@ -5937,6 +5938,7 @@ breakText Glyphs Glyphes @msg Select horizontal point or area type whose text fits Sélectionnez du texte de point ou captif horizontal dont le texte tient dans son cadre @msg Nothing to split at the cursor Rien à diviser au curseur @msg Select two or more one-line point type objects on one baseline to combine Sélectionnez au moins deux objets texte de point d’une ligne sur une même ligne de base pour les combiner +@msg Select two or more one-line point type objects to join Sélectionnez au moins deux objets texte de point d’une ligne pour les joindre @msg No line breaks to remove Aucun saut de ligne à supprimer @msg No paragraph breaks to remove Aucun saut de paragraphe à supprimer @msg Too many breaks to remove at once Trop de sauts à supprimer en une fois diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index cc72129c0a..a17b4724fb 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5897,6 +5897,7 @@ anchor Points: Pontok: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text Szöveg felosztása Combine Point Text Pontszöveg egyesítése + Join Point Text Pontszöveg összekapcsolása Remove Breaks Törések eltávolítása Split Text at Cursor Szöveg felosztása a kurzornál Set Alignment, Keep Position Igazítás beállítása, helyzet megtartása @@ -5925,6 +5926,7 @@ breakText Glyphs Glifák @msg Select horizontal point or area type whose text fits Jelöljön ki vízszintes pont- vagy területszöveget, amelynek szövege elfér @msg Nothing to split at the cursor A kurzornál nincs mit felosztani @msg Select two or more one-line point type objects on one baseline to combine Az egyesítéshez jelöljön ki legalább két egysoros pontszöveg-objektumot egy alapvonalon +@msg Select two or more one-line point type objects to join Az összekapcsoláshoz jelöljön ki legalább két egysoros pontszöveg-objektumot @msg No line breaks to remove Nincs eltávolítandó sortörés @msg No paragraph breaks to remove Nincs eltávolítandó bekezdéstörés @msg Too many breaks to remove at once Túl sok törés az egyszerre történő eltávolításhoz diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 34ca64d532..85f09a6a6d 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5815,6 +5815,7 @@ anchor Points: Titik: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text Pisahkan teks Combine Point Text Gabungkan teks titik + Join Point Text Satukan teks titik Remove Breaks Hapus pemisah Split Text at Cursor Pisahkan teks di kursor Set Alignment, Keep Position Atur perataan, pertahankan posisi @@ -5843,6 +5844,7 @@ breakText Glyphs Glif @msg Select horizontal point or area type whose text fits Pilih teks titik atau teks area horizontal yang teksnya muat @msg Nothing to split at the cursor Tidak ada yang dapat dipisahkan di kursor @msg Select two or more one-line point type objects on one baseline to combine Pilih dua atau lebih objek teks titik satu baris pada satu garis dasar untuk menggabungkannya +@msg Select two or more one-line point type objects to join Pilih dua atau lebih objek teks titik satu baris untuk menyatukannya @msg No line breaks to remove Tidak ada putus baris untuk dihapus @msg No paragraph breaks to remove Tidak ada putus paragraf untuk dihapus @msg Too many breaks to remove at once Terlalu banyak pemisah untuk dihapus sekaligus diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7eabb61750..8be70bb1ff 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5529,6 +5529,7 @@ anchor Points: Punti: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text Dividi testo Combine Point Text Combina testo con punto + Join Point Text Unisci testo con punto Remove Breaks Rimuovi interruzioni Split Text at Cursor Dividi testo al cursore Set Alignment, Keep Position Imposta allineamento mantenendo la posizione @@ -5557,6 +5558,7 @@ breakText Glyphs Glifi @msg Select horizontal point or area type whose text fits Seleziona testo con punto o in area orizzontale il cui testo ci sta @msg Nothing to split at the cursor Niente da dividere al cursore @msg Select two or more one-line point type objects on one baseline to combine Seleziona due o più oggetti di testo con punto su una riga sulla stessa linea di base per combinarli +@msg Select two or more one-line point type objects to join Seleziona due o più oggetti di testo con punto su una riga per unirli @msg No line breaks to remove Nessuna interruzione di riga da rimuovere @msg No paragraph breaks to remove Nessuna interruzione di paragrafo da rimuovere @msg Too many breaks to remove at once Troppe interruzioni da rimuovere in una volta diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b08889efda..c7acb2adf3 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5595,6 +5595,7 @@ anchor Points: ポイント: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text テキストを分割 Combine Point Text ポイント文字を結合 + Join Point Text ポイント文字を連結 Remove Breaks 改行を削除 Split Text at Cursor カーソル位置でテキストを分割 Set Alignment, Keep Position 位置を保って行揃えを設定 @@ -5623,6 +5624,7 @@ breakText Glyphs 字形 @msg Select horizontal point or area type whose text fits テキストが収まっている横組みのポイント文字またはエリア内文字を選択してください @msg Nothing to split at the cursor カーソル位置で分割するものがありません @msg Select two or more one-line point type objects on one baseline to combine 結合するには、同じベースライン上にある 1 行のポイント文字オブジェクトを 2 つ以上選択してください +@msg Select two or more one-line point type objects to join 連結するには、1 行のポイント文字オブジェクトを 2 つ以上選択してください @msg No line breaks to remove 削除する強制改行がありません @msg No paragraph breaks to remove 削除する段落の区切りがありません @msg Too many breaks to remove at once 一度に削除する改行が多すぎます diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 208ff506be..903d741c74 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5900,6 +5900,7 @@ anchor Points: წერტილები: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text ტექსტის დაყოფა Combine Point Text წერტილის ტექსტის გაერთიანება + Join Point Text წერტილის ტექსტის შეერთება Remove Breaks წყვეტების წაშლა Split Text at Cursor ტექსტის დაყოფა კურსორთან Set Alignment, Keep Position სწორების დაყენება მდებარეობის შენარჩუნებით @@ -5928,6 +5929,7 @@ breakText Glyphs გლიფები @msg Select horizontal point or area type whose text fits აირჩიეთ ჰორიზონტალური წერტილის ან არის ტექსტი, რომლის ტექსტიც ეტევა @msg Nothing to split at the cursor კურსორთან დასაყოფი არაფერია @msg Select two or more one-line point type objects on one baseline to combine გასაერთიანებლად აირჩიეთ ერთ საბაზისო ხაზზე მდებარე ორი ან მეტი ერთსტრიქონიანი წერტილის ტექსტის ობიექტი +@msg Select two or more one-line point type objects to join შესაერთებლად აირჩიეთ ორი ან მეტი ერთსტრიქონიანი წერტილის ტექსტის ობიექტი @msg No line breaks to remove წასაშლელი სტრიქონის წყვეტები არ არის @msg No paragraph breaks to remove წასაშლელი აბზაცის წყვეტები არ არის @msg Too many breaks to remove at once ერთდროულად წასაშლელად ძალიან ბევრი წყვეტაა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 6be4d08703..732af24f62 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5492,6 +5492,7 @@ anchor Points: 포인트: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text 텍스트 나누기 Combine Point Text 포인트 문자 결합 + Join Point Text 포인트 문자 연결 Remove Breaks 나누기 제거 Split Text at Cursor 커서 위치에서 텍스트 나누기 Set Alignment, Keep Position 위치를 유지하며 정렬 설정 @@ -5520,6 +5521,7 @@ breakText Glyphs 글리프 @msg Select horizontal point or area type whose text fits 텍스트가 모두 들어가는 가로 포인트 문자 또는 영역 문자를 선택하십시오 @msg Nothing to split at the cursor 커서 위치에서 나눌 내용이 없습니다 @msg Select two or more one-line point type objects on one baseline to combine 결합하려면 같은 기준선에 있는 한 줄짜리 포인트 문자 오브젝트를 두 개 이상 선택하십시오 +@msg Select two or more one-line point type objects to join 연결하려면 한 줄짜리 포인트 문자 오브젝트를 두 개 이상 선택하십시오 @msg No line breaks to remove 제거할 강제 줄 바꿈이 없습니다 @msg No paragraph breaks to remove 제거할 단락 나누기가 없습니다 @msg Too many breaks to remove at once 한 번에 제거할 나누기가 너무 많습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index d7a608c311..effdca022d 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5859,6 +5859,7 @@ anchor Points: Punten: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text Tekst splitsen Combine Point Text Punttekst combineren + Join Point Text Punttekst samenvoegen Remove Breaks Einden verwijderen Split Text at Cursor Tekst splitsen bij cursor Set Alignment, Keep Position Uitlijning instellen, positie behouden @@ -5887,6 +5888,7 @@ breakText Glyphs Glyphs @msg Select horizontal point or area type whose text fits Selecteer horizontale punt- of vlaktekst waarvan de tekst past @msg Nothing to split at the cursor Niets te splitsen bij de cursor @msg Select two or more one-line point type objects on one baseline to combine Selecteer twee of meer punttekstobjecten van één regel op één basislijn om ze te combineren +@msg Select two or more one-line point type objects to join Selecteer twee of meer punttekstobjecten van één regel om ze samen te voegen @msg No line breaks to remove Geen regeleinden om te verwijderen @msg No paragraph breaks to remove Geen alinea-einden om te verwijderen @msg Too many breaks to remove at once Te veel einden om in één keer te verwijderen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 63dd77d7d6..6be694f622 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5765,6 +5765,7 @@ anchor Points: Punkty: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text Podziel tekst Combine Point Text Połącz tekst punktowy + Join Point Text Złącz tekst punktowy Remove Breaks Usuń podziały Split Text at Cursor Podziel tekst w miejscu kursora Set Alignment, Keep Position Ustaw wyrównanie, zachowaj położenie @@ -5793,6 +5794,7 @@ breakText Glyphs Glify @msg Select horizontal point or area type whose text fits Zaznacz poziomy tekst punktowy lub akapitowy, którego tekst się mieści @msg Nothing to split at the cursor Nie ma nic do podzielenia w miejscu kursora @msg Select two or more one-line point type objects on one baseline to combine Zaznacz co najmniej dwa jednowierszowe obiekty tekstu punktowego na jednej linii bazowej, aby je połączyć +@msg Select two or more one-line point type objects to join Zaznacz co najmniej dwa jednowierszowe obiekty tekstu punktowego, aby je złączyć @msg No line breaks to remove Brak podziałów wiersza do usunięcia @msg No paragraph breaks to remove Brak podziałów akapitu do usunięcia @msg Too many breaks to remove at once Zbyt wiele podziałów do usunięcia naraz diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 94508e9659..5fe9922eda 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5828,6 +5828,7 @@ anchor Points: Pontos: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text Dividir Texto Combine Point Text Combinar Texto de Ponto + Join Point Text Unir Texto de Ponto Remove Breaks Remover Quebras Split Text at Cursor Dividir Texto no Cursor Set Alignment, Keep Position Definir Alinhamento, Manter Posição @@ -5856,6 +5857,7 @@ breakText Glyphs Glifos @msg Select horizontal point or area type whose text fits Selecione texto de ponto ou de área horizontal cujo texto caiba @msg Nothing to split at the cursor Nada a dividir no cursor @msg Select two or more one-line point type objects on one baseline to combine Selecione dois ou mais objetos de texto de ponto de uma linha na mesma linha de base para combiná-los +@msg Select two or more one-line point type objects to join Selecione dois ou mais objetos de texto de ponto de uma linha para uni-los @msg No line breaks to remove Nenhuma quebra de linha a remover @msg No paragraph breaks to remove Nenhuma quebra de parágrafo a remover @msg Too many breaks to remove at once Quebras demais para remover de uma vez diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 37e6eb061e..bd4faa713d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5888,6 +5888,7 @@ anchor Points: Точки: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text Разбить текст Combine Point Text Объединить текст из точки + Join Point Text Соединить текст из точки Remove Breaks Удалить разрывы Split Text at Cursor Разбить текст у курсора Set Alignment, Keep Position Задать выравнивание, сохранив положение @@ -5916,6 +5917,7 @@ breakText Glyphs Глифы @msg Select horizontal point or area type whose text fits Выделите горизонтальный текст из точки или в области, текст которого помещается @msg Nothing to split at the cursor У курсора нечего разбивать @msg Select two or more one-line point type objects on one baseline to combine Выделите два или более однострочных объекта текста из точки на одной базовой линии, чтобы объединить их +@msg Select two or more one-line point type objects to join Выделите два или более однострочных объекта текста из точки, чтобы соединить их @msg No line breaks to remove Нет разрывов строк для удаления @msg No paragraph breaks to remove Нет разрывов абзацев для удаления @msg Too many breaks to remove at once Слишком много разрывов для удаления за один раз diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fbed797583..273720935b 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5963,6 +5963,7 @@ shape Open Öppen # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text Dela upp text Combine Point Text Kombinera punkttext + Join Point Text Sammanfoga punkttext Remove Breaks Ta bort brytningar Split Text at Cursor Dela text vid markören Set Alignment, Keep Position Ställ in justering, behåll position @@ -5991,6 +5992,7 @@ breakText Glyphs Glyfer @msg Select horizontal point or area type whose text fits Markera vågrät punkt- eller områdestext vars text får plats @msg Nothing to split at the cursor Inget att dela vid markören @msg Select two or more one-line point type objects on one baseline to combine Markera två eller fler enradiga punkttextobjekt på samma baslinje för att kombinera dem +@msg Select two or more one-line point type objects to join Markera två eller fler enradiga punkttextobjekt för att sammanfoga dem @msg No line breaks to remove Inga radbrytningar att ta bort @msg No paragraph breaks to remove Inga styckebrytningar att ta bort @msg Too many breaks to remove at once För många brytningar att ta bort på en gång diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index aabe5c6452..180f3448da 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5868,6 +5868,7 @@ anchor Points: Noktalar: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text Metni Böl Combine Point Text Nokta Metnini Birleştir + Join Point Text Nokta Metnini Bağla Remove Breaks Kesmeleri Kaldır Split Text at Cursor Metni İmleçte Böl Set Alignment, Keep Position Hizalamayı Ayarla, Konumu Koru @@ -5896,6 +5897,7 @@ breakText Glyphs Glifler @msg Select horizontal point or area type whose text fits Metni sığan yatay nokta veya alan metni seçin @msg Nothing to split at the cursor İmleçte bölünecek bir şey yok @msg Select two or more one-line point type objects on one baseline to combine Birleştirmek için aynı taban çizgisinde iki veya daha fazla tek satırlı nokta metni nesnesi seçin +@msg Select two or more one-line point type objects to join Bağlamak için iki veya daha fazla tek satırlı nokta metni nesnesi seçin @msg No line breaks to remove Kaldırılacak satır sonu yok @msg No paragraph breaks to remove Kaldırılacak paragraf sonu yok @msg Too many breaks to remove at once Bir kerede kaldırılacak çok fazla kesme var diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index b3553b0445..4d267593f6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5509,6 +5509,7 @@ anchor Points: Точки: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text Розбити текст Combine Point Text Об’єднати текст із точки + Join Point Text З’єднати текст із точки Remove Breaks Видалити розриви Split Text at Cursor Розбити текст біля курсора Set Alignment, Keep Position Задати вирівнювання, зберігши положення @@ -5537,6 +5538,7 @@ breakText Glyphs Гліфи @msg Select horizontal point or area type whose text fits Виділіть горизонтальний текст із точки або в області, текст якого вміщується @msg Nothing to split at the cursor Біля курсора нічого розбивати @msg Select two or more one-line point type objects on one baseline to combine Виділіть два або більше однорядкових об’єктів тексту з точки на одній базовій лінії, щоб об’єднати їх +@msg Select two or more one-line point type objects to join Виділіть два або більше однорядкових об’єктів тексту з точки, щоб з’єднати їх @msg No line breaks to remove Немає розривів рядків для видалення @msg No paragraph breaks to remove Немає розривів абзаців для видалення @msg Too many breaks to remove at once Забагато розривів, щоб видалити їх за раз diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4bb1fd1226..67169df4de 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5511,6 +5511,7 @@ anchor Points: Điểm: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text Tách văn bản Combine Point Text Kết hợp chữ điểm + Join Point Text Nối chữ điểm Remove Breaks Xóa ngắt Split Text at Cursor Tách văn bản tại con trỏ Set Alignment, Keep Position Đặt căn lề, giữ vị trí @@ -5539,6 +5540,7 @@ breakText Glyphs Glyph @msg Select horizontal point or area type whose text fits Chọn chữ điểm hoặc chữ vùng nằm ngang có văn bản vừa khung @msg Nothing to split at the cursor Không có gì để tách tại con trỏ @msg Select two or more one-line point type objects on one baseline to combine Chọn hai đối tượng chữ điểm một dòng trở lên trên cùng một đường cơ sở để kết hợp +@msg Select two or more one-line point type objects to join Chọn hai đối tượng chữ điểm một dòng trở lên để nối @msg No line breaks to remove Không có xuống dòng nào để xóa @msg No paragraph breaks to remove Không có ngắt đoạn nào để xóa @msg Too many breaks to remove at once Quá nhiều ngắt để xóa cùng lúc diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index bd495d05d6..6da63132e7 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5752,6 +5752,7 @@ anchor Points: 点: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text 拆分文本 Combine Point Text 合并点文字 + Join Point Text 连接点文字 Remove Breaks 删除换行 Split Text at Cursor 在光标处拆分文本 Set Alignment, Keep Position 设置对齐并保持位置 @@ -5780,6 +5781,7 @@ breakText Glyphs 字形 @msg Select horizontal point or area type whose text fits 请选择文本能完全容纳的横排点文字或区域文字 @msg Nothing to split at the cursor 光标处没有可拆分的内容 @msg Select two or more one-line point type objects on one baseline to combine 请选择同一基线上两个或更多单行点文字对象以合并 +@msg Select two or more one-line point type objects to join 请选择两个或更多单行点文字对象以连接 @msg No line breaks to remove 没有要删除的强制换行 @msg No paragraph breaks to remove 没有要删除的段落换行 @msg Too many breaks to remove at once 一次要删除的换行过多 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f69992edd9..7e21abda50 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5548,6 +5548,7 @@ anchor Points: 點: # Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. Break Text 分割文字 Combine Point Text 合併點文字 + Join Point Text 連接點文字 Remove Breaks 移除換行 Split Text at Cursor 在游標處分割文字 Set Alignment, Keep Position 設定對齊並保持位置 @@ -5576,6 +5577,7 @@ breakText Glyphs 字符 @msg Select horizontal point or area type whose text fits 請選取文字能完整容納的橫排點文字或區域文字 @msg Nothing to split at the cursor 游標處沒有可分割的內容 @msg Select two or more one-line point type objects on one baseline to combine 請選取同一基線上兩個或更多單行點文字物件以合併 +@msg Select two or more one-line point type objects to join 請選取兩個或更多單行點文字物件以連接 @msg No line breaks to remove 沒有要移除的強制換行 @msg No paragraph breaks to remove 沒有要移除的段落換行 @msg Too many breaks to remove at once 一次要移除的換行過多 diff --git a/docs/mcp.md b/docs/mcp.md index 71ae5c02d0..a4756b6768 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5773,6 +5773,11 @@ glyph of its first character lands where it was drawn. more than 1 em, another look (paint, opacity, effects) or a piece that can't keep its place once joined (a ligature forming across the join) starts another object. Each object made takes the place and paint of its leftmost piece. The main use is type from a PDF that arrives one glyph per object. +- `addon.cleanup.joinText {ids?}` (Join Point Text) joins one-line horizontal point type into one point type object, + for text that arrives one line or fragment per object. Pieces turned and sheared like the top-left one are read as + lines (baselines within 0.4 em), top to bottom; a line's pieces join left to right with a space between them. Each + line becomes a left-aligned paragraph whose leading is the distance from the baseline above, so the lines stay where + they were. Character styles stay; the object takes the top-left piece's place and paint. Returns `{id, joined, lines}`. - `addon.cleanup.removeBreaks {kind, ids?}` removes the forced line breaks (`kind: "line"`) or paragraph returns (`"paragraph"`) in the characters selected with a Type tool, else in all the text of the selected type. A break between two words becomes a space; merged paragraphs take the first one's paragraph style. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8712f26f5f..8d930ef07e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G13: Direct Selection snaps a dragged line end square to straight paths and grazing curves; Path Precision Tools add-on: Shared Edge Merge Tool; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Artwork Cleanup add-on: Join Point Text, #4826; scores unchanged; previously: G13: Direct Selection snaps a dragged line end square to straight paths and grazing curves; Path Precision Tools add-on: Shared Edge Merge Tool; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -19,6 +19,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Artwork Cleanup add-on: Join Point Text (`addon.cleanup.joinText`) joins stacked one-line point type into one object, a paragraph per line keeping its baseline (#4826); scores unchanged. | | 2026-10-31 | minor | Width tool: a stroke with a width profile keeps its shape while the view zooms out; the minimum visible width goes to its widest point instead of scaling the whole profile (#995); scores unchanged. | | 2026-10-31 | minor | Patterns: Grid Repeat Options has a Preview checkbox, as the public grid options page lists (#2651); scores unchanged. | | 2026-10-31 | minor | Hardening: a gradient mesh read from a file is budgeted at about a million quads, so a huge one no longer exhausts memory when drawn or exported (#3592); scores unchanged. | @@ -1244,7 +1245,8 @@ alone; Fix All as one step; scopes; rejoin within the gap only) and `addons::cle The add-on's text tools (2026-10-31) work on the shared text layout: Break Text into lines, paragraphs, words or glyphs and Split Text at Cursor make point type pieces placed by the glyph of their first character, Combine Point Text merges -one-line point type back with every glyph where it was (tracking and spaces, checked against the layout), and Remove +one-line point type back with every glyph where it was (tracking and spaces, checked against the layout), Join Point +Text stacks one-line pieces into one object of paragraphs that keep their baselines (#4826), and Remove Line / Paragraph Breaks, Set Alignment, Keep Position, Reset Text Transform and Replace Missing Fonts. Tests: `cmd::addons::cleanup::text::tests` in `vectorcraft-engine` (glyphs kept in place, styles kept, one undo step, refusals), `addons::cleanup::tests` in `vectorcraft-ui-egui` (the submenu and the panel's Text row run them) and From b8e5f7088f06d2c131655adff59eded8c0736d18 Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Sat, 31 Oct 2026 21:08:59 +0700 Subject: [PATCH 90/98] Export for Screens: Export PDFs As Single file writes the artboards as one PDF's pages --- crates/engine/src/cmd/fileio/commands.rs | 2 +- crates/engine/src/cmd/fileio/screens.rs | 35 +++++++++++++++++-- crates/engine/src/cmd/fileio/tests_screens.rs | 22 ++++++++++++ docs/mcp.md | 5 +-- 4 files changed, 58 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 0c38079dff..abd6857adc 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)", + "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)", has_doc, screens::export_for_screens ), diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index db934aeab7..c6ff1e8d6a 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -309,6 +309,9 @@ struct ScreenParams { /// Where files go: `none` (all in the folder), `rows` (the rows' sub-folders, as /// `subfolders`), `format` (a folder per extension), `item`, or `itemFormat`. folders: Option<String>, + /// Export PDFs As: `single` (the artboards a PDF row writes are the pages of one file) or + /// `multiple` (a file per artboard, the default). + pdfs: Option<String>, } /// How exported files are sorted into folders. @@ -389,6 +392,8 @@ struct Naming { /// The counter counts files rather than items. per_file: bool, folders: Folders, + /// Export PDFs As › Single file. + single_pdf: bool, } impl Naming { @@ -406,9 +411,14 @@ impl Naming { Some("file") => true, Some(c) => return Err(bad(C, format!("counterPer `{c}`: item or file"))), }; + let single_pdf = match o.pdfs.as_deref() { + None | Some("multiple") => false, + Some("single") => true, + Some(m) => return Err(bad(C, format!("pdfs `{m}`: single or multiple"))), + }; let filename = safe_name(&super::file_stem(title)).unwrap_or_else(|| "Untitled".into()); let pattern = o.name.clone().filter(|n| !n.trim().is_empty()); - Ok(Self { pattern, prefix, filename, when, counter_start: o.counter_start.unwrap_or(1), per_file, folders }) + Ok(Self { pattern, prefix, filename, when, counter_start: o.counter_start.unwrap_or(1), per_file, folders, single_pdf }) } /// The sub-folder (`None` for the export folder itself) and the file name of the file row `r` @@ -781,15 +791,34 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) -> // One step per item and row, the files a name collision skips included. let steps = (items.len() * rows.len()) as u64; let mut step = 0u64; + // Export PDFs As › Single file: with several artboards, a PDF row writes them all as the pages + // of one file, named as the document, with the first of them. + let artboards: Option<Vec<usize>> = items.iter().map(|i| i.artboard).collect::<Option<Vec<_>>>().filter(|a| naming.single_pdf && a.len() > 1); for (index, item) in items.iter().enumerate() { for r in &rows { vectorcraft_doc::cancel::check()?; vectorcraft_doc::cancel::progress(step, steps, &format!("Exporting file {} of {steps}", step + 1)); step += 1; - let (sub, file) = naming.file(item, index, files, r); + let single = artboards.as_ref().filter(|_| r.format.id == "pdf"); + if single.is_some() && index > 0 { + continue; + } + let document = single.map(|_| Item { + name: naming.filename.clone(), + stem: naming.filename.clone(), + doc: item.doc.clone(), + artboard: None, + region: item.region, + every_artboard: None, + }); + let (sub, file) = naming.file(document.as_ref().unwrap_or(item), index, files, r); let name = sub.map_or_else(|| file.clone(), |d| format!("{d}/{file}")); let Some(name) = out.claim(&name, &format!("{}|{}", item.stem, r.key), o.name.is_some()) else { continue }; - let bytes = if let (Some(all), "pdf") = (&item.every_artboard, r.format.id) { + let bytes = if let Some(boards) = single { + let mut options = r.options.clone(); + options["range"] = json!(boards.iter().map(|b| (b + 1).to_string()).collect::<Vec<_>>().join(", ")); + write(&item.doc, "pdf", &options)? + } else if let (Some(all), "pdf") = (&item.every_artboard, r.format.id) { // One page per artboard. let mut options = r.options.clone(); options["range"] = json!("all"); diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index 5da4bdf5e4..bb4e356259 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -811,3 +811,25 @@ fn the_document_remembers_its_export_settings() { assert!(!plain.contains("export_settings") && !plain.contains("exportSettings"), "not written when empty"); assert!(vectorcraft_format::load(plain.as_bytes()).unwrap().export_settings.is_empty()); } + +/// Export PDFs As › Single file: with several artboards, a PDF row writes them as the pages of one +/// file named after the document (raster rows still write a file per artboard); with one artboard +/// the PDF is that artboard's, as Multiple files writes it. +#[test] +fn single_file_pdfs_hold_every_chosen_artboard() { + let mut s = session(3); + let title = s.doc().unwrap().doc.title.clone(); + let rows = json!([{"format": "png", "scale": "1x"}, {"format": "pdf"}]); + let r = export(&mut s, json!({"range": "1-2", "pdfs": "single", "formats": rows})); + let got = files(&r); + let pdf: Vec<&(String, Vec<u8>)> = got.iter().filter(|(n, _)| n.ends_with(".pdf")).collect(); + assert_eq!(pdf.len(), 1, "{:?}", names(&r)); + assert_eq!(pdf[0].0, format!("{title}.pdf")); + assert_eq!(vectorcraft_pdf::import(&pdf[0].1).unwrap().artboards.len(), 2, "one page per artboard"); + assert_eq!(got.iter().filter(|(n, _)| n.ends_with(".png")).count(), 2); + let r = export(&mut s, json!({"range": "2", "pdfs": "single", "formats": [{"format": "pdf"}]})); + assert_eq!(names(&r), ["Artboard-2.pdf"]); + let r = export(&mut s, json!({"range": "1-2", "pdfs": "multiple", "formats": [{"format": "pdf"}]})); + assert_eq!(names(&r), ["Artboard-1.pdf", "Artboard-2.pdf"]); + assert!(s.execute("document.exportForScreens", &json!({"pdfs": "both", "formats": [{"format": "pdf"}]})).is_err()); +} diff --git a/docs/mcp.md b/docs/mcp.md index a4756b6768..c1568ae63c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3987,8 +3987,9 @@ each file deflated, or stored when deflating does not make it smaller. A row is `{format, scale?}` where `scale` is a factor (`2`, `"2x"`), a pixel width (`"100w"` or `width: 100`), a height (`"100h"`) or a resolution (`"72ppi"`); raster rows name their files `@2x`, `@100w`… unless `suffix` says otherwise. `fullDocument` writes one file per row instead (a PDF of every artboard, other formats the bounds of all -art), `includeBleed` grows each artboard by the document's bleed, `subfolders` puts each row's files in a sub-folder -(its size or format, or its own `folder`), `preset: "mobile"` or `"density"` (Android-style ldpi…xxxhdpi +art), `includeBleed` grows each artboard by the document's bleed, `pdfs: "single"` (Export PDFs As › Single file) +writes the artboards a PDF row exports as the pages of one PDF named after the document, `subfolders` puts each +row's files in a sub-folder (its size or format, or its own `folder`; `folders: "format"` sorts by format instead), `preset: "mobile"` or `"density"` (Android-style ldpi…xxxhdpi sub-folders) replaces the rows, and `settings: {png: {…}, jpg: {…}, svg: {…}, pdf: {preset}}` gives every row of a format its options. On every platform, `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters from a prefix, a suffix, a name pattern or an item's name become `-` in file and folder names, a folder name ends without From 7a2acbc178602956b48d2799473900ac6d623be1 Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Sat, 31 Oct 2026 21:16:28 +0700 Subject: [PATCH 91/98] Export for Screens: the dialog is laid out as Illustrator's --- .../ui-egui/src/dialogs/export_for_screens.rs | 740 +++++++++--------- .../src/dialogs/tests_accessibility.rs | 6 +- .../src/dialogs/tests_screen_assets.rs | 11 +- crates/ui-egui/src/dialogs/tests_screens.rs | 257 +++--- crates/ui-egui/src/i18n/bg.tsv | 8 + crates/ui-egui/src/i18n/bs.tsv | 8 + crates/ui-egui/src/i18n/ca.tsv | 8 + crates/ui-egui/src/i18n/cs.tsv | 8 + crates/ui-egui/src/i18n/da.tsv | 8 + crates/ui-egui/src/i18n/de.tsv | 8 + crates/ui-egui/src/i18n/el.tsv | 8 + crates/ui-egui/src/i18n/es.tsv | 8 + crates/ui-egui/src/i18n/fi.tsv | 8 + crates/ui-egui/src/i18n/fr.tsv | 8 + crates/ui-egui/src/i18n/hu.tsv | 8 + crates/ui-egui/src/i18n/id.tsv | 8 + crates/ui-egui/src/i18n/it.tsv | 8 + crates/ui-egui/src/i18n/ja.tsv | 8 + crates/ui-egui/src/i18n/ka.tsv | 8 + crates/ui-egui/src/i18n/ko.tsv | 8 + crates/ui-egui/src/i18n/nl.tsv | 8 + crates/ui-egui/src/i18n/pl.tsv | 8 + crates/ui-egui/src/i18n/pt-br.tsv | 8 + crates/ui-egui/src/i18n/ru.tsv | 8 + crates/ui-egui/src/i18n/sv.tsv | 8 + crates/ui-egui/src/i18n/tr.tsv | 8 + crates/ui-egui/src/i18n/uk.tsv | 8 + crates/ui-egui/src/i18n/vi.tsv | 8 + crates/ui-egui/src/i18n/zh-hans.tsv | 8 + crates/ui-egui/src/i18n/zh-hant.tsv | 8 + crates/ui-egui/src/io/export.rs | 2 + crates/ui-egui/src/panels/asset_export.rs | 2 +- .../ui-egui/src/panels/tests_asset_export.rs | 2 +- 33 files changed, 673 insertions(+), 555 deletions(-) diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index 7bc4ca8b7a..fdb48a7536 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -1,34 +1,31 @@ -//! File → Export → Export for Screens: the Artboards tab (All, a range or Full Document, Include -//! Bleed, thumbnails with checkboxes) and the Assets tab (the Asset Export panel's assets, with -//! checkboxes), the destination (folder picker, Open Location after Export, Create Sub-folders; -//! the web downloads instead), the format rows (scale as `2x`, `100w`, `100h` or `72ppi`, suffix, -//! format), the presets, Format Settings (the gear) and the prefix. It opens on the settings the -//! document last exported with, except that while the Artboard tool is in use the artboards it has -//! selected are the ones chosen; the format rows are the Asset Export panel's too ([`formats`]). +//! File → Export → Export for Screens, laid out as Illustrator's: on the left the Artboards and +//! Assets tabs (thumbnails with checkboxes, in a grid or a list), Clear Selection and the prefix; +//! on the right Select (All, a range or Full Document, Include Bleed), the destination (folder +//! picker, Open Location after Export, Create Sub-folders by scale or by format; the web downloads +//! instead), Export PDFs As (one file of every artboard or a file each), and the format rows (scale +//! as `2x`, `100w`, `100h` or `72ppi`, suffix, format) with the iOS and Android rows, the format +//! presets (the ☰ menu) and Format Settings (the gear); below, how many items are selected and +//! how many files the export writes. It opens on the settings the document last exported with, +//! except that while the Artboard tool is in use the artboards it has selected are the ones chosen; +//! the format rows are the Asset Export panel's too ([`formats`]). On the desktop with Background +//! Export on, the files are encoded and written on the background thread +//! ([`crate::background`]) after the dialog closes. //! -//! The Layers tab exports each checked layer's art alone, and the Selection tab the selected art as -//! one file or a file per object. A file name pattern (`name`: `<filename>`, `<name>`, `####`, -//! `YYYYMMDD`…, see `document.exportForScreens`) names every file alike, with a live example, and -//! the files go into one folder, or a folder per row, format, item, or item and format -//! (`folders`). Export presets (built-in and saved) set the formats, naming and folders at once. -//! On the desktop with Background Export on, the files are encoded and written on the background -//! thread ([`crate::background`]) after the dialog closes. +//! `document.exportForScreens` does more than the dialog shows (layers, the selection, file name +//! patterns, a folder per item): those are for scripts and MCP. //! -//! Fields (`ui.dialog.set`): `tab` (artboards|assets|layers|selection), `select` (all|range|full), `range`, +//! Fields (`ui.dialog.set`): `tab` (artboards|assets), `select` (all|range|full), `range`, //! `boards` ([bool] per artboard), `assets` ([asset id] checked on the Assets tab), `includeBleed`, -//! `folder`, `openLocation`, `subfolders`, `prefix`, `preset` (""|mobile|density: where the rows -//! came from), `formats` (rows of `document.exportForScreens`, `scale` as text), `settings` -//! ({png|png8|jpg|webp|gif|tiff|svg|pdf: {…}}), `layers` ([layer id] checked on the Layers tab), -//! `selectionMode` (one|each), `name` (the pattern), `counterStart`, `folders` -//! (none|rows|format|item|itemFormat), `exportPreset` (the preset applied last, "" for none); +//! `folder`, `openLocation`, `subfolders`, `subfolderBy` (scale|format), `pdfs` (single|multiple), +//! `prefix`, `preset` (""|mobile|density: where the rows came from), `formats` (rows of +//! `document.exportForScreens`, `scale` as text), `settings` ({png|png8|jpg|webp|gif|tiff|svg|pdf: +//! {…}}), `view` (grid|list), `exportPreset` (the format preset applied last, "" for none); //! `__settings` names the format whose Format Settings show ("" when closed), `__savePreset` is the //! name being typed to save a preset (absent when not saving). use serde::Deserialize; use serde_json::{Map, Value, json}; -use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, SCREEN_PRESETS, ScreenSize, pdf}; - -use vectorcraft_doc::{ExportAsset, NodeId}; +use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, ScreenSize, pdf}; use super::{DialogSpec, form, png_options, svg_options}; use crate::state::Dialog; @@ -43,11 +40,9 @@ pub(super) const SPEC: DialogSpec = DialogSpec { ok_label: Some(|app| match (io::is_web(app), app.ui.dialog.as_ref().map(|d| d.str("tab")).as_deref()) { (true, _) => "Download", (false, Some("assets")) => "Export Asset", - (false, Some("layers")) => "Export Layer", - (false, Some("selection")) => "Export Selection", (false, _) => "Export Artboard", }), - min_width: 660.0, + min_width: 720.0, ..DialogSpec::FORM }; @@ -80,32 +75,13 @@ fn print_marks() -> Value { json!({"trim": true, "registration": true, "colorBars": true, "pageInfo": true}) } -/// Where the files go: (`folders` value, label). -const FOLDERS: [(&str, &str); 5] = [ - ("none", "All in One Folder"), - ("rows", "By Size or Row"), - ("format", "By Format"), - ("item", "By Artboard or Item"), - ("itemFormat", "By Item, Then Format"), -]; -const FOLDER_LABELS: [&str; 5] = ["All in One Folder", "By Size or Row", "By Format", "By Artboard or Item", "By Item, Then Format"]; - -/// The tokens Insert adds to the file name pattern: (label, token). -const TOKENS: [(&str, &str); 8] = [ - ("Document Name", "<filename>"), - ("Artboard or Item Name", "<name>"), - ("Number (####)", "####"), - ("Date (YYYYMMDD)", "YYYYMMDD"), - ("Date (YYYY-MM-DD)", "YYYY-MM-DD"), - ("Time (hhmmss)", "<time>"), - ("Format", "<format>"), - ("Scale", "<scale>"), -]; -const TOKEN_LABELS: [&str; 8] = - ["Document Name", "Artboard or Item Name", "Number (####)", "Date (YYYYMMDD)", "Date (YYYY-MM-DD)", "Time (hhmmss)", "Format", "Scale"]; - /// The tabs: (`tab` value, label). -const TABS: [&str; 4] = ["artboards", "assets", "layers", "selection"]; +const TABS: [&str; 2] = ["artboards", "assets"]; + +/// The left pane's width and the height of its thumbnails' area; the right pane's width. +const LEFT_W: f32 = 300.0; +const PANE_H: f32 = 360.0; +const RIGHT_W: f32 = 370.0; /// The formats Format Settings has options for: (format, label). const SETTINGS: [(&str, &str); 8] = @@ -144,8 +120,9 @@ pub(crate) fn open_assets(app: &mut VectorcraftApp, checked: Option<&[u64]>) { } /// Open the dialog on the settings the active document last exported with (else one PNG row at -/// 1x, into the Desktop), every asset checked. While the Artboard tool is in use, the artboards it -/// has selected (the rows the Artboards panel highlights) are the ones chosen instead. +/// 1x, into the Desktop, with Create Sub-folders on), every asset checked. While the Artboard tool +/// is in use, the artboards it has selected (the rows the Artboards panel highlights) are the ones +/// chosen instead. pub fn open(app: &mut VectorcraftApp) { let (n, saved, assets) = app .session @@ -172,12 +149,14 @@ pub fn open(app: &mut VectorcraftApp) { settings.insert(id.into(), Value::Object(saved_options(app, &saved, id))); } let folder = get("folder").and_then(Value::as_str).filter(|f| !f.is_empty()).map(str::to_string).or_else(fileio::export_folder); - // Where files go: the saved choice, else a folder per row as Create Sub-folders did. - let folders = get("folders") - .and_then(Value::as_str) - .filter(|f| FOLDERS.iter().any(|(id, _)| id == f)) - .map_or_else(|| if flag("subfolders", preset.is_some()) { "rows" } else { "none" }.to_string(), str::to_string); - let layers: Vec<u64> = app.session.active().map(|st| st.doc.layers.iter().map(|l| l.id.0).collect()).unwrap_or_default(); + // Create Sub-folders is on until an export turns it off; a folder per format is the saved + // `folders: "format"`, any other sorting a folder per scale. + let folders = get("folders").and_then(Value::as_str); + let subfolders = match folders { + Some("none") => false, + Some(_) => true, + None => flag("subfolders", saved.is_empty() || preset.is_some()), + }; let fields = json!({ "tab": "artboards", "select": select, @@ -187,18 +166,16 @@ pub fn open(app: &mut VectorcraftApp) { "includeBleed": flag("includeBleed", false), "folder": folder.unwrap_or_default(), "openLocation": flag("openLocation", true), - "subfolders": flag("subfolders", preset.is_some()), + "subfolders": subfolders, + "subfolderBy": if folders == Some("format") { "format" } else { "scale" }, + "pdfs": if get("pdfs").and_then(Value::as_str) == Some("single") { "single" } else { "multiple" }, "prefix": get("prefix").and_then(Value::as_str).unwrap_or_default(), "preset": preset.unwrap_or_default(), "formats": rows, "settings": settings, + "view": "grid", "__settings": "", "__presets": pdf::presets(&app.session), - "layers": layers, - "selectionMode": get("selection").and_then(Value::as_str).filter(|m| *m == "each").unwrap_or("one"), - "name": get("name").and_then(Value::as_str).unwrap_or_default(), - "counterStart": get("counterStart").and_then(Value::as_u64).unwrap_or(1), - "folders": folders, "exportPreset": "", "__exportPresets": export_presets(app), }); @@ -281,24 +258,6 @@ pub(super) fn export_presets(app: &mut VectorcraftApp) -> Value { app.run("document.exportPresets.list", json!({})).ok().and_then(|r| r.get("presets").cloned()).unwrap_or_else(|| json!([])) } -/// The art of layer `id` (sublayers unfolded), back to front: what its file holds. -fn layer_art(doc: &vectorcraft_doc::Document, id: NodeId, out: &mut Vec<NodeId>) { - let Some(n) = doc.node(id) else { return }; - if n.is_layer() { - for c in n.children().into_iter().flatten() { - layer_art(doc, c.id, out); - } - } else { - out.push(id); - } -} - -/// The checked layers that are still in the document, in stacking order. -fn checked_layers(app: &VectorcraftApp, d: &Dialog) -> Vec<u64> { - let on: Vec<u64> = d.fields.get("layers").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.layers.iter().map(|l| l.id.0).filter(|id| on.contains(id)).collect()).unwrap_or_default() -} - /// The `document.exportForScreens` params the dialog describes (the web: no folder, one ZIP for /// several files). fn params(app: &VectorcraftApp, d: &Dialog) -> Result<Value, String> { @@ -316,24 +275,24 @@ fn params(app: &VectorcraftApp, d: &Dialog) -> Result<Value, String> { Ok(Value::Object(p)) } -/// The counter's first value: the Start at field (a number, or text typed through `ui.dialog.set`). -fn counter_start(d: &Dialog) -> u64 { - d.fields.get("counterStart").and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|t| t.trim().parse().ok()))).unwrap_or(1) -} - /// The params of [`params`] without the destination (what is exported, at which sizes, and how the /// files are named and sorted), and how many files that is. fn settings_params(app: &VectorcraftApp, d: &Dialog) -> Result<(Map<String, Value>, usize), String> { + let (p, files, _) = selection_params(app, d)?; + Ok((p, files)) +} + +/// [`settings_params`] and how many artboards or assets are chosen. +fn selection_params(app: &VectorcraftApp, d: &Dialog) -> Result<(Map<String, Value>, usize, usize), String> { let n = app.session.active().map_or(0, |st| st.doc.artboards.len()); let rows = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default(); if rows.is_empty() { return Err("add a format to export".into()); } let mut p = Map::new(); - // What each row writes a file of: the checked assets, layers, the selection, else the - // artboards. - let tab = d.str("tab"); - let pieces = if tab == "assets" { + // What each row writes a file of: the checked assets, else the artboards. + let artboards = d.str("tab") != "assets"; + let pieces = if !artboards { let checked = checked_assets(app, d); if checked.is_empty() { return Err("check an asset to export".into()); @@ -341,22 +300,6 @@ fn settings_params(app: &VectorcraftApp, d: &Dialog) -> Result<(Map<String, Valu let count = checked.len(); p.insert("assets".into(), json!(checked)); count - } else if tab == "layers" { - let checked = checked_layers(app, d); - if checked.is_empty() { - return Err("check a layer to export".into()); - } - let count = checked.len(); - p.insert("layers".into(), json!(checked)); - count - } else if tab == "selection" { - let selected = app.session.active().map_or(0, |st| st.selection.objects.len()); - if selected == 0 { - return Err("select the art to export first".into()); - } - let each = d.str("selectionMode") == "each"; - p.insert("selection".into(), json!(if each { "each" } else { "one" })); - if each { selected } else { 1 } } else { match d.str("select").as_str() { "full" => { @@ -367,34 +310,33 @@ fn settings_params(app: &VectorcraftApp, d: &Dialog) -> Result<(Map<String, Valu let range = d.str("range"); // A bad range keeps the dialog open. let chosen = ArtboardPick { range: Some(range.clone()), ..Default::default() }.resolve(n)?.unwrap_or_default(); + if chosen.is_empty() { + return Err("check an artboard to export".into()); + } p.insert("range".into(), json!(range)); chosen.len() } _ => n, } }; - let files = pieces * rows.len(); + // Export PDFs As › Single file: every chosen artboard in one PDF per PDF row. + let single = artboards && pieces > 1 && d.str("select") != "full" && d.str("pdfs") == "single"; + let pdf_rows = rows.iter().filter(|r| r["format"].as_str().and_then(fileio::format).is_some_and(|f| f.id == "pdf")).count(); + let files = if single { (rows.len() - pdf_rows) * pieces + pdf_rows } else { rows.len() * pieces }; p.insert("includeBleed".into(), json!(d.bool("includeBleed"))); - let folders = d.str("folders"); - let folders = match (FOLDERS.iter().any(|(id, _)| *id == folders), d.bool("subfolders")) { - // Create Sub-folders (as the Asset Export panel and older scripts set it) is a folder per - // row while Folders is still All in One Folder. - (true, true) if folders == "none" => "rows".to_string(), - (true, _) => folders, - (false, sub) => if sub { "rows" } else { "none" }.to_string(), + let folders = match (d.bool("subfolders"), d.str("subfolderBy").as_str()) { + (false, _) => "none", + (true, "format") => "format", + (true, _) => "rows", }; - // Create Sub-folders, as the Asset Export panel (which shares these settings) still names it. + // Create Sub-folders, as the Asset Export panel (which shares these settings) names it. p.insert("subfolders".into(), json!(folders == "rows")); p.insert("folders".into(), json!(folders)); + p.insert("pdfs".into(), json!(if single { "single" } else { "multiple" })); p.insert("prefix".into(), json!(d.str("prefix"))); - let name = d.str("name"); - if !name.trim().is_empty() { - p.insert("name".into(), json!(name.trim())); - } - p.insert("counterStart".into(), json!(counter_start(d))); p.insert("formats".into(), Value::Array(rows)); p.insert("settings".into(), d.fields.get("settings").cloned().unwrap_or(json!({}))); - Ok((p, files)) + Ok((p, files, pieces)) } /// Export; the dialog stays open when the export fails. @@ -410,130 +352,218 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> { fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let t = Tokens::get(ui.ctx()); let tab = TABS.iter().position(|k| *k == d.str("tab")).unwrap_or(0); - if let Some(i) = widgets::tab_bar(ui, &[tl!("Artboards"), tl!("Assets"), tl!("Layers"), tl!("Selection")], tab) { - d.fields.insert("tab".into(), json!(TABS.get(i).copied().unwrap_or("artboards"))); - } - ui.add_space(8.0); ui.horizontal_top(|ui| { ui.vertical(|ui| { - ui.set_width(270.0); - match tab { - 1 => assets(app, ui, d), - 2 => layers(app, ui, d), - 3 => selection(app, ui, d), - _ => artboards(app, ui, d), + ui.set_width(LEFT_W); + if let Some(i) = widgets::tab_bar(ui, &[tl!("Artboards"), tl!("Assets")], tab) { + d.fields.insert("tab".into(), json!(TABS.get(i).copied().unwrap_or("artboards"))); } + ui.add_space(6.0); + let list = d.str("view") == "list"; + let (pane, _) = ui.allocate_exact_size(egui::vec2(LEFT_W, PANE_H), egui::Sense::hover()); + ui.painter().rect_filled(pane, 2.0, t.panel_darker); + ui.painter().rect_stroke(pane, 2.0, egui::Stroke::new(1.0, t.divider), egui::StrokeKind::Inside); + let mut inner = ui.new_child(egui::UiBuilder::new().max_rect(pane.shrink(8.0)).layout(egui::Layout::top_down(egui::Align::Min))); + match tab { + 1 => assets(app, &mut inner, d, list), + _ => artboard_tiles(app, &mut inner, d, list), + } + ui.add_space(6.0); + ui.horizontal(|ui| { + for (view, icon, tip) in [("grid", "layout-grid", tl!("Grid View")), ("list", "dc-list-view", tl!("List View"))] { + if widgets::icon_button(ui, icon, tip, d.str("view") == view || (view == "grid" && !list && d.str("view") != "list"), 22.0) + .clicked() + { + d.fields.insert("view".into(), json!(view)); + } + } + ui.add_space(6.0); + if ui.small_button(tl!("Clear Selection")).clicked() { + clear_selection(app, d, tab); + } + widgets::dim_label(ui, tl!("Prefix:")); + form::text(ui, d, "prefix", (ui.available_width() - 4.0).max(60.0)); + }); }); - let (sep, _) = ui.allocate_exact_size(egui::vec2(17.0, 380.0), egui::Sense::hover()); + let (sep, _) = ui.allocate_exact_size(egui::vec2(17.0, PANE_H + 60.0), egui::Sense::hover()); ui.painter().line_segment([sep.center_top(), sep.center_bottom()], egui::Stroke::new(1.0, t.divider)); ui.vertical(|ui| { - ui.set_width(350.0); + ui.set_width(RIGHT_W); let shown = d.str("__settings"); if SETTINGS.iter().any(|(id, _)| *id == shown) { format_settings(ui, d, &shown); } else { - presets(app, ui, d); - ui.add_space(8.0); + if tab == 0 { + select_options(app, ui, d); + ui.add_space(8.0); + } destination(app, ui, d); + ui.add_space(6.0); + pdf_mode(ui, d, tab == 0); ui.add_space(10.0); - formats(ui, d); - ui.add_space(8.0); - naming(app, ui, d); + formats(ui, d, Some(&mut |ui: &mut egui::Ui, d: &mut Dialog| preset_menu(app, ui, d))); + save_preset_row(app, ui, d); } }); }); + ui.add_space(8.0); + let (picked, files) = counts(app, d); + let status = + crate::i18n::fmt(tl!("Selected: {n}, Total number of files to export: {m}"), &[("n", &picked.to_string()), ("m", &files.to_string())]); + widgets::dim_label(ui, &status); false } +/// How many artboards or assets are chosen and how many files the export writes (0 while the +/// settings choose nothing to export). +fn counts(app: &VectorcraftApp, d: &Dialog) -> (usize, usize) { + match selection_params(app, d) { + Ok((_, files, pieces)) => (pieces, files), + Err(_) => (if d.str("tab") == "assets" { checked_assets(app, d).len() } else { 0 }, 0), + } +} + +/// Clear Selection: no artboard (or, on the Assets tab, no asset) is checked. +fn clear_selection(app: &VectorcraftApp, d: &mut Dialog, tab: usize) { + if tab == 1 { + d.fields.insert("assets".into(), json!([])); + } else { + let n = app.session.active().map_or(0, |st| st.doc.artboards.len()); + d.fields.insert("boards".into(), json!(vec![false; n])); + set_select(d, "range"); + d.fields.insert("range".into(), json!("")); + } +} + fn set_select(d: &mut Dialog, select: &str) { d.fields.insert("select".into(), json!(select)); } -/// Select (All, Range, Full Document), Include Bleed, and the artboards' thumbnails and checkboxes. -fn artboards(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { +/// The checked artboards, one per artboard (all checked when the field is short). +fn boards(app: &VectorcraftApp, d: &Dialog) -> Vec<bool> { let n = app.session.active().map_or(0, |st| st.doc.artboards.len()); let mut boards: Vec<bool> = d.fields.get("boards").and_then(Value::as_array).map(|a| a.iter().map(|b| b.as_bool().unwrap_or(false)).collect()).unwrap_or_default(); boards.resize(n, true); - let before = boards.clone(); + boards +} + +/// Select: All or a Range (which checks the artboards it names), Include Bleed and Full Document. +fn select_options(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + let n = app.session.active().map_or(0, |st| st.doc.artboards.len()); let select = d.str("select"); widgets::dim_label(ui, tl!("Select:")); ui.horizontal(|ui| { if widgets::radio(ui, tl!("All"), select == "all", true) { set_select(d, "all"); - boards.iter_mut().for_each(|b| *b = true); + d.fields.insert("boards".into(), json!(vec![true; n])); + d.fields.insert("range".into(), json!(range_text(&vec![true; n]))); } if widgets::radio(ui, tl!("Range:"), select == "range", true) { set_select(d, "range"); } ui.add_enabled_ui(select == "range", |ui| { - if form::text_named(ui, d, "range", tl!("Range:"), 90.0) + if form::text_named(ui, d, "range", tl!("Range:"), 150.0) && let Ok(Some(chosen)) = (ArtboardPick { range: Some(d.str("range")), ..Default::default() }).resolve(n) { - boards = (0..n).map(|i| chosen.contains(&i)).collect(); + d.fields.insert("boards".into(), json!((0..n).map(|i| chosen.contains(&i)).collect::<Vec<_>>())); } }); }); - if widgets::radio(ui, tl!("Full Document"), select == "full", true) { + form::check(ui, d, "includeBleed", tl!("Include Bleed")); + if widgets::radio(ui, tl!("Full Document"), d.str("select") == "full", true) { set_select(d, "full"); } - ui.add_space(4.0); - form::check(ui, d, "includeBleed", tl!("Include Bleed")); - ui.add_space(6.0); - let typed = boards != before; - let checked = boards.clone(); +} + +/// The artboards' thumbnails, each with its checkbox, number and name, in a grid or a list; +/// checking them picks them as the range (all of them: All). Dimmed with Full Document. +fn artboard_tiles(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, list: bool) { + let mut boards = boards(app, d); + let before = boards.clone(); + let names: Vec<String> = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default(); ui.add_enabled_ui(d.str("select") != "full", |ui| { - ui.horizontal(|ui| { - if ui.small_button(tl!("Select All")).clicked() { - boards.iter_mut().for_each(|b| *b = true); + tiles(ui, "efs-artboards", names.len(), list, |ui, i, r| { + match artboard_thumb(app, ui.ctx(), i) { + Some(tex) => { + let sz = tex.size_vec2(); + let s = (r.width() / sz.x).min(r.height() / sz.y).min(1.0); + let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); + ui.painter().image(tex.id(), egui::Rect::from_center_size(r.center(), sz * s), uv, egui::Color32::WHITE); + } + None => { + ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE); + } } - if ui.small_button(tl!("Clear")).clicked() { - boards.iter_mut().for_each(|b| *b = false); - } - }); - let names: Vec<String> = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default(); - egui::ScrollArea::vertical().max_height(250.0).show(ui, |ui| { - for (i, name) in names.iter().enumerate() { - ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover()); - match artboard_thumb(app, ui.ctx(), i) { - Some(tex) => { - let sz = tex.size_vec2(); - let s = (46.0 / sz.x.max(sz.y)).min(1.0); - let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); - ui.painter().image(tex.id(), egui::Rect::from_center_size(r.center(), sz * s), uv, egui::Color32::WHITE); - } - None => { - ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE); - } - } - if let Some(b) = boards.get_mut(i) - && crate::widgets::check_name(ui, name, *b) - { - *b ^= true; - } - }); + let name = names.get(i).cloned().unwrap_or_default(); + (format!("{} {name}", i + 1), name, boards.get(i).copied().unwrap_or(false)) + }) + .into_iter() + .for_each(|i| { + if let Some(b) = boards.get_mut(i) { + *b ^= true; } }); }); - // Checking artboards picks them as the range (all of them: All); a typed range keeps its text. - if boards != checked { + if boards != before { set_select(d, if boards.iter().all(|b| *b) { "all" } else { "range" }); d.fields.insert("range".into(), json!(range_text(&boards))); - } - if typed || boards != checked { d.fields.insert("boards".into(), json!(boards)); } } +/// `count` thumbnails in a grid (or, with `list`, a list) that scrolls: `paint` draws thumbnail `i` +/// into its rect and gives its caption, its checkbox's name and whether it is checked → the +/// thumbnails whose checkbox (or picture) was clicked. +fn tiles( + ui: &mut egui::Ui, + id: &str, + count: usize, + list: bool, + mut paint: impl FnMut(&mut egui::Ui, usize, egui::Rect) -> (String, String, bool), +) -> Vec<usize> { + let t = Tokens::get(ui.ctx()); + let mut toggled = vec![]; + let side = if list { 40.0 } else { 84.0 }; + let width = ui.available_width(); + let columns = if list { 1 } else { ((width + 10.0) / (side + 10.0)).floor().max(1.0) as usize }; + egui::ScrollArea::vertical().id_salt(id).auto_shrink([false, false]).show(ui, |ui| { + for start in (0..count).step_by(columns) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 10.0; + for i in start..(start + columns).min(count) { + let cell = if list { egui::vec2(width - 16.0, side) } else { egui::vec2(side, side + 18.0) }; + let (r, resp) = ui.allocate_exact_size(cell, egui::Sense::click()); + let pic = egui::Rect::from_min_size(r.min, egui::vec2(side, side)); + let (caption, name, checked) = paint(ui, i, pic.shrink(3.0)); + let stroke = if checked { egui::Stroke::new(2.0, t.accent) } else { egui::Stroke::new(1.0, t.divider) }; + ui.painter().rect_stroke(pic, 2.0, stroke, egui::StrokeKind::Inside); + let label_at = if list { egui::pos2(pic.right() + 8.0, pic.center().y) } else { egui::pos2(pic.left(), pic.bottom() + 9.0) }; + let galley = + ui.painter().layout(caption, egui::FontId::proportional(11.0), t.text, if list { cell.x - side - 12.0 } else { side }); + ui.painter().galley(egui::pos2(label_at.x, label_at.y - galley.size().y / 2.0), galley, t.text); + // The checkbox sits on the thumbnail's lower left corner, as Illustrator's. + let bx = egui::Rect::from_min_size(egui::pos2(pic.left() + 4.0, pic.bottom() - 18.0), egui::vec2(14.0, 14.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(bx)); + if widgets::check_box(&mut child, &name, checked, true) || resp.clicked() { + toggled.push(i); + } + } + }); + ui.add_space(6.0); + } + }); + toggled +} + /// The checked assets that are still in the document, in panel order. fn checked_assets(app: &VectorcraftApp, d: &Dialog) -> Vec<u64> { let on: Vec<u64> = 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 Assets tab: each asset's thumbnail and checkbox, Select All and Clear. -fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { +/// The Assets tab: each 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() { ui.add_space(24.0); @@ -542,39 +572,30 @@ fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { } let mut on = checked_assets(app, d); let before = on.clone(); - ui.horizontal(|ui| { - if ui.small_button(tl!("Select All")).clicked() { - on = st.doc.assets.iter().map(|a| a.id).collect(); - } - if ui.small_button(tl!("Clear")).clicked() { - on.clear(); - } - }); - // Asked for until the row is in view: the dialog's first frame only measures it. + // 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 mut in_view = scroll_to.is_none(); - egui::ScrollArea::vertical().max_height(330.0).show(ui, |ui| { - for a in &st.doc.assets { - let row = ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover()); - crate::panels::asset_export::paint_thumb(ui, st, a, r); - let checked = on.contains(&a.id); - if crate::widgets::check_name(ui, &a.name, checked) { - on.retain(|id| *id != a.id); - if !checked { - on.push(a.id); - } - } - }); - if scroll_to == Some(a.id) { - if ui.clip_rect().contains_rect(row.response.rect) { - in_view = true; - } else { - row.response.scroll_to_me(Some(egui::Align::Center)); - } + 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) }; + crate::panels::asset_export::paint_thumb(ui, st, a, r); + if scroll_to == Some(a.id) { + if ui.clip_rect().contains_rect(r) { + in_view = true; + } else { + ui.scroll_to_rect(r, Some(egui::Align::Center)); } } + (a.name.clone(), a.name.clone(), on.contains(&a.id)) }); + for i in clicked { + if let Some(a) = st.doc.assets.get(i) { + if on.contains(&a.id) { + on.retain(|id| *id != a.id); + } else { + on.push(a.id); + } + } + } if in_view { d.fields.remove("__scrollTo"); } @@ -583,8 +604,8 @@ fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { } } -/// Export to: the folder (typed or picked), Open Location after Export and Create Sub-folders; the -/// web downloads instead. +/// Export to: the folder (typed or picked), Open Location after Export, and Create Sub-folders, +/// a folder per scale or per format; the web downloads instead. fn destination(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { widgets::dim_label(ui, tl!("Export to:")); if io::is_web(app) { @@ -592,78 +613,111 @@ fn destination(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { } else { let pick = app.services.pick_folder.is_some(); ui.horizontal(|ui| { - form::text(ui, d, "folder", if pick { 300.0 } else { 334.0 }); + form::text(ui, d, "folder", if pick { RIGHT_W - 40.0 } else { RIGHT_W - 6.0 }); if pick && widgets::icon_button(ui, "folder-open", tl!("Choose a folder"), false, 26.0).clicked() { crate::picks::folder_field(app, d, "folder"); } }); form::check(ui, d, "openLocation", tl!("Open Location after Export")); } + form::check(ui, d, "subfolders", tl!("Create Sub-folders")); ui.horizontal(|ui| { - widgets::dim_label(ui, tl!("Folders:")); - let current = d.str("folders"); - let at = FOLDERS.iter().position(|(id, _)| *id == current).unwrap_or(0); - if let Some((id, _)) = widgets::dropdown(ui, "efs-folders", FOLDER_LABELS[at], &FOLDER_LABELS, 190.0).and_then(|i| FOLDERS.get(i)) { - d.fields.insert("folders".into(), json!(id)); - d.fields.insert("subfolders".into(), json!(*id == "rows")); + ui.add_space(22.0); + let on = d.bool("subfolders"); + let by = d.str("subfolderBy"); + if widgets::radio(ui, tl!("Scale"), by != "format", on) { + d.fields.insert("subfolderBy".into(), json!("scale")); + } + if widgets::radio(ui, tl!("Format"), by == "format", on) { + d.fields.insert("subfolderBy".into(), json!("format")); } }); } -/// Export Preset: apply a built-in or saved preset (its formats, settings, naming and folders), -/// save the dialog's settings as one (Save…, then a name), or delete a saved one. -fn presets(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { +/// Export PDFs As: one file of every chosen artboard, or a file each; only for artboards (not +/// Full Document) with a PDF row. +fn pdf_mode(ui: &mut egui::Ui, d: &mut Dialog, artboards: bool) { + let pdf = d + .fields + .get("formats") + .and_then(Value::as_array) + .is_some_and(|rows| rows.iter().any(|r| r["format"].as_str().and_then(fileio::format).is_some_and(|f| f.id == "pdf"))); + let on = artboards && pdf && d.str("select") != "full"; + ui.horizontal(|ui| { + ui.add_enabled_ui(on, |ui| widgets::dim_label(ui, tl!("Export PDFs As:"))); + let single = d.str("pdfs") == "single"; + if widgets::radio(ui, tl!("Single File"), single, on) { + d.fields.insert("pdfs".into(), json!("single")); + } + if widgets::radio(ui, tl!("Multiple Files"), !single, on) { + d.fields.insert("pdfs".into(), json!("multiple")); + } + }); +} + +/// The format presets (the ☰ by iOS and Android): apply a built-in or saved one (its rows, their +/// settings, the prefix and the sub-folders), save the dialog's rows as one, or delete the saved +/// one applied last. +fn preset_menu(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { let list: Vec<Value> = d.fields.get("__exportPresets").and_then(Value::as_array).cloned().unwrap_or_default(); - let names: Vec<&str> = list.iter().filter_map(|p| p["name"].as_str()).collect(); let current = d.str("exportPreset"); let saved = list.iter().any(|p| p["name"] == current && p["builtIn"] == false); - ui.horizontal(|ui| { - widgets::dim_label(ui, tl!("Export Preset:")); - let labels: Vec<&str> = std::iter::once("Custom").chain(names.iter().copied()).collect(); - let at = names.iter().position(|n| *n == current).map_or(0, |i| i + 1); - if let Some(i) = widgets::dropdown(ui, "efs-export-preset", labels[at], &labels, 150.0) - && let Some(preset) = i.checked_sub(1).and_then(|i| list.get(i)) - { - apply_preset(d, preset); - } - if widgets::icon_button(ui, "save", tl!("Save Preset…"), false, 24.0).clicked() { - let name = if saved { current.clone() } else { String::new() }; - d.fields.insert("__savePreset".into(), json!(name)); - } - ui.add_enabled_ui(saved, |ui| { - if widgets::icon_button(ui, "trash-2", tl!("Delete Preset"), false, 24.0).clicked() - && app.run("document.exportPresets.delete", json!({"name": current})).is_ok() - { - d.fields.insert("exportPreset".into(), json!("")); - d.fields.insert("__exportPresets".into(), export_presets(app)); + let resp = widgets::icon_button(ui, "menu", tl!("Format Presets"), false, 24.0); + let clicked = resp.clicked(); + let _ = widgets::popover(&resp, clicked, |ui| { + for p in &list { + let name = p["name"].as_str().unwrap_or_default(); + if widgets::menu_item_name(ui, name, true, name == current) { + apply_preset(d, p); + ui.close(); } - }); + } + ui.separator(); + if widgets::menu_item(ui, tl!("Save Preset…"), true, false) { + d.fields.insert("__savePreset".into(), json!(if saved { current.clone() } else { String::new() })); + ui.close(); + } + if widgets::menu_item(ui, tl!("Delete Preset"), saved, false) && app.run("document.exportPresets.delete", json!({"name": current})).is_ok() { + d.fields.insert("exportPreset".into(), json!("")); + d.fields.insert("__exportPresets".into(), export_presets(app)); + ui.close(); + } }); - if d.fields.contains_key("__savePreset") { - ui.horizontal(|ui| { - widgets::dim_label(ui, tl!("Name:")); - form::text(ui, d, "__savePreset", 150.0); - let name = d.str("__savePreset").trim().to_string(); - if ui.add_enabled(!name.is_empty(), egui::Button::new(tl!("Save"))).clicked() { - let settings = params(app, d).unwrap_or_else(|_| json!({"formats": d.fields.get("formats").cloned().unwrap_or(json!([]))})); - match app.run("document.exportPresets.save", json!({"name": name, "settings": settings})) { - Ok(_) => { - d.fields.remove("__savePreset"); - d.fields.insert("exportPreset".into(), json!(name)); - d.fields.insert("__exportPresets".into(), export_presets(app)); - } - Err(e) => app.ui.status = e, - } - } - if ui.small_button(tl!("Cancel")).clicked() { - d.fields.remove("__savePreset"); - } - }); - } } -/// Set the dialog to preset `preset` (`{name, settings}`): its formats, settings, naming and -/// folders; what it doesn't name keeps the dialog's value. +/// The row that names a format preset being saved (Save Preset… in the ☰ menu). +fn save_preset_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + if !d.fields.contains_key("__savePreset") { + return; + } + ui.horizontal(|ui| { + widgets::dim_label(ui, tl!("Name:")); + form::text(ui, d, "__savePreset", 150.0); + let name = d.str("__savePreset").trim().to_string(); + if ui.add_enabled(!name.is_empty(), egui::Button::new(tl!("Save"))).clicked() { + let settings = json!({ + "formats": d.fields.get("formats").cloned().unwrap_or(json!([])), + "settings": d.fields.get("settings").cloned().unwrap_or(json!({})), + "prefix": d.str("prefix"), + "folders": if !d.bool("subfolders") { "none" } else if d.str("subfolderBy") == "format" { "format" } else { "rows" }, + }); + match app.run("document.exportPresets.save", json!({"name": name, "settings": settings})) { + Ok(_) => { + d.fields.remove("__savePreset"); + d.fields.insert("exportPreset".into(), json!(name)); + d.fields.insert("__exportPresets".into(), export_presets(app)); + } + Err(e) => app.ui.status = e, + } + } + if ui.small_button(tl!("Cancel")).clicked() { + d.fields.remove("__savePreset"); + } + }); +} + +/// Set the dialog to format preset `preset` (`{name, settings}`): its rows, their settings, the +/// prefix and the sub-folders; what it doesn't name keeps the dialog's value. pub(super) fn apply_preset(d: &mut Dialog, preset: &Value) { let st = &preset["settings"]; if let Some(rows) = st["formats"].as_array() { @@ -680,127 +734,29 @@ pub(super) fn apply_preset(d: &mut Dialog, preset: &Value) { } let folders = st["folders"].as_str().or_else(|| st["subfolders"].as_bool().map(|b| if b { "rows" } else { "none" })); if let Some(f) = folders { - d.fields.insert("folders".into(), json!(f)); - d.fields.insert("subfolders".into(), json!(f == "rows")); + d.fields.insert("subfolders".into(), json!(f != "none")); + d.fields.insert("subfolderBy".into(), json!(if f == "format" { "format" } else { "scale" })); } - for k in ["name", "prefix"] { - d.fields.insert(k.into(), json!(st[k].as_str().unwrap_or_default())); + if let Some(prefix) = st["prefix"].as_str() { + d.fields.insert("prefix".into(), json!(prefix)); } - d.fields.insert("counterStart".into(), json!(st["counterStart"].as_u64().unwrap_or(1))); if let Some(b) = st["includeBleed"].as_bool() { d.fields.insert("includeBleed".into(), json!(b)); } - if let Some(f) = st["folder"].as_str().filter(|f| !f.is_empty()) { - d.fields.insert("folder".into(), json!(f)); - } d.fields.insert("exportPreset".into(), json!(preset["name"])); } -/// File Name: the pattern (with Insert for its tokens), the counter's start, the prefix and an -/// example of the first file's name. -fn naming(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { - ui.horizontal(|ui| { - widgets::dim_label(ui, tl!("File Name:")); - // A pattern typed by hand is no longer the preset's. - if form::text(ui, d, "name", 170.0) { - d.fields.insert("exportPreset".into(), json!("")); - } - if let Some((_, token)) = - widgets::dropdown_named(ui, "efs-name-token", Some(tl!("Insert")), tl!("Insert"), &TOKEN_LABELS, 90.0).and_then(|i| TOKENS.get(i)) - { - let name = d.str("name"); - d.fields.insert("name".into(), json!(format!("{name}{token}"))); - } - }); - ui.horizontal(|ui| { - widgets::dim_label(ui, tl!("Prefix:")); - form::text(ui, d, "prefix", 110.0); - widgets::dim_label(ui, tl!("Start at:")); - if let Some(v) = widgets::range_field(ui, "efs-counter-start", counter_start(d) as f64, 0.0..=999_999.0, "", 0, 64.0) { - d.fields.insert("counterStart".into(), json!(v.round().max(0.0) as u64)); - } - }); - let example = example_name(app, d); - if !example.is_empty() { - widgets::dim_label(ui, &crate::i18n::fmt(tl!("Example: {name}"), &[("name", &example)])); - } -} +/// What the dialog adds by the iOS and Android buttons: its format presets. +pub(crate) type FormatMenu<'a> = &'a mut dyn FnMut(&mut egui::Ui, &mut Dialog); -/// The first file the export writes, relative to the export folder, as `document.exportForScreens` -/// names it ([`fileio::screen_first_file`]); empty while the dialog's settings choose nothing to -/// export (no format, nothing checked or selected, a bad range). -pub(super) fn example_name(app: &VectorcraftApp, d: &Dialog) -> String { - settings_params(app, d).ok().and_then(|(p, _)| fileio::screen_first_file(&app.session, &Value::Object(p)).ok()).unwrap_or_default() -} +/// The iOS and Android rows (the engine's `mobile` and `density` presets) and their labels. +const PLATFORMS: [(&str, &str); 2] = [("mobile", "iOS"), ("density", "Android")]; -/// The Layers tab: each top-level layer's thumbnail and checkbox (a file of its art alone), -/// Select All and Clear. -fn layers(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { - let Some(st) = app.session.active() else { return }; - let mut on = checked_layers(app, d); - let before = on.clone(); - ui.horizontal(|ui| { - if ui.small_button(tl!("Select All")).clicked() { - on = st.doc.layers.iter().map(|l| l.id.0).collect(); - } - if ui.small_button(tl!("Clear")).clicked() { - on.clear(); - } - }); - egui::ScrollArea::vertical().max_height(330.0).show(ui, |ui| { - for l in &st.doc.layers { - ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover()); - let mut art = vec![]; - layer_art(&st.doc, l.id, &mut art); - // An id of its own for the thumbnail cache, away from the assets' ids. - let a = ExportAsset { id: u64::MAX - l.id.0, name: l.display_name(), nodes: art }; - crate::panels::asset_export::paint_thumb(ui, st, &a, r); - let checked = on.contains(&l.id.0); - if crate::widgets::check_name(ui, &l.display_name(), checked) { - on.retain(|id| *id != l.id.0); - if !checked { - on.push(l.id.0); - } - } - }); - } - }); - if on != before { - d.fields.insert("layers".into(), json!(on)); - } -} - -/// The Selection tab: what is selected (a thumbnail and the count), as one file or a file per -/// object. -fn selection(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { - let Some(st) = app.session.active() else { return }; - let ids = st.selection.objects.clone(); - if ids.is_empty() { - ui.add_space(24.0); - widgets::dim_label(ui, tl!("Nothing is selected. Select the art to export, then open this tab again.")); - return; - } - let (r, _) = ui.allocate_exact_size(egui::vec2(120.0, 120.0), egui::Sense::hover()); - // The selection changes without a new revision: its ids name its thumbnail. - let key = ids.iter().fold(0x005e_1ec7_u64, |h, id| h.rotate_left(7) ^ id.0); - let a = ExportAsset { id: u64::MAX / 2 + (key % (u64::MAX / 4)), name: "Selection".into(), nodes: ids.clone() }; - crate::panels::asset_export::paint_thumb(ui, st, &a, r); - let count = ids.len().to_string(); - widgets::dim_label(ui, &crate::i18n::fmt(tl!("{n} objects selected"), &[("n", &count)])); - ui.add_space(6.0); - let mode = d.str("selectionMode"); - if widgets::radio(ui, tl!("One File of the Selection"), mode != "each", true) { - d.fields.insert("selectionMode".into(), json!("one")); - } - if widgets::radio(ui, tl!("A File per Object"), mode == "each", true) { - d.fields.insert("selectionMode".into(), json!("each")); - } -} - -/// The preset, the Format Settings gear (which sets `__settings`), the format rows (scale, suffix, -/// format) and Add Scale, as wide as `ui` allows: the dialog's and the Asset Export panel's rows. -pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) { +/// Formats: the iOS and Android buttons (each sets the rows that platform uses), `menu` (the +/// dialog's format presets), the Format Settings gear (which sets `__settings`), the format rows +/// (scale, suffix, format) and Add Scale, as wide as `ui` allows: the dialog's and the Asset +/// Export panel's rows. +pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog, mut menu: Option<FormatMenu<'_>>) { let mut rows: Vec<Value> = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default(); let mut edited = false; ui.horizontal(|ui| { @@ -816,19 +772,21 @@ pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) { .filter(|f| SETTINGS.iter().any(|(id, _)| id == f)); d.fields.insert("__settings".into(), json!(first.unwrap_or("png"))); } - let preset = d.str("preset"); - let labels: Vec<&str> = std::iter::once("Custom").chain(SCREEN_PRESETS.iter().map(|(_, l)| *l)).collect(); - let at = SCREEN_PRESETS.iter().position(|(id, _)| *id == preset).map_or(0, |i| i + 1); - if let Some(i) = - widgets::dropdown_named(ui, "efs-preset", Some(tl!("Preset:")), labels[at], &labels, (ui.available_width() - 8.0).clamp(90.0, 180.0)) - { - let id = i.checked_sub(1).and_then(|i| SCREEN_PRESETS.get(i)).map_or("", |(id, _)| *id); - if let Some(preset_rows) = fileio::screen_preset_rows(id) { + if let Some(menu) = menu.as_mut() { + menu(ui, d); + } + // Right to left: Android, then iOS before it. + for (id, label) in PLATFORMS.iter().rev() { + let on = d.str("preset") == *id; + if ui.add(egui::Button::new(egui::RichText::new(tl!(label)).size(12.0)).selected(on).frame(on)).clicked() + && let Some(preset_rows) = fileio::screen_preset_rows(id) + { rows = preset_rows; d.fields.insert("subfolders".into(), json!(true)); + d.fields.insert("subfolderBy".into(), json!("scale")); d.fields.insert("formats".into(), json!(rows)); + d.fields.insert("preset".into(), json!(id)); } - d.fields.insert("preset".into(), json!(id)); } }); }); diff --git a/crates/ui-egui/src/dialogs/tests_accessibility.rs b/crates/ui-egui/src/dialogs/tests_accessibility.rs index 42ee3256b4..ac469f6b72 100644 --- a/crates/ui-egui/src/dialogs/tests_accessibility.rs +++ b/crates/ui-egui/src/dialogs/tests_accessibility.rs @@ -39,16 +39,16 @@ fn strict(app: VectorcraftApp) -> Harness { h } -/// Export for Screens names its three text fields and exposes no raw widget ids. +/// Export for Screens names its range and prefix fields and exposes no raw widget ids. #[test] fn export_for_screens_names_the_form_text_fields() { let mut app = app(); app.run("file.exportForScreens", json!({})).unwrap(); let h = strict(app); - for name in ["File Name:", "Prefix:", "Start at:"] { + for name in ["Range:", "Prefix:"] { let _ = h.get_by_role_and_label(Role::TextInput, name); } - assert!(h.query_by_role_and_label(Role::TextInput, "name").is_none(), "no internal widget id"); + assert!(h.query_by_role_and_label(Role::TextInput, "range").is_none(), "no internal widget id"); assert!(h.query_by_role_and_label(Role::TextInput, "prefix").is_none(), "no internal widget id"); } diff --git a/crates/ui-egui/src/dialogs/tests_screen_assets.rs b/crates/ui-egui/src/dialogs/tests_screen_assets.rs index 9bfd4f5078..32f585e812 100644 --- a/crates/ui-egui/src/dialogs/tests_screen_assets.rs +++ b/crates/ui-egui/src/dialogs/tests_screen_assets.rs @@ -65,10 +65,10 @@ fn export_selection_collects_the_selection_and_opens_the_assets_tab() { assert_eq!(assets(&mut app).len(), 2); assert_eq!(field(&app, "assets"), json!(ids)); let text = painted_text(&mut app, |app, ui| super::show(app, ui.ctx())); - assert!(text.contains("Asset 1") && text.contains("Asset 2") && text.contains("Select All"), "{text}"); - // The web downloads both as one zip. + assert!(text.contains("Asset 1") && text.contains("Asset 2") && text.contains("Clear Selection"), "{text}"); + // The web downloads both as one zip, in the 1x sub-folder Create Sub-folders makes by default. let r = confirm(&mut app).unwrap(); - assert_eq!(r["files"], json!(["Asset-1.png", "Asset-2.png"])); + assert_eq!(r["files"], json!(["1x/Asset-1.png", "1x/Asset-2.png"])); assert_eq!(log.borrow().len(), 1); assert!(app.ui.dialog.is_none()); // Nothing selected: Export Selection is off; document.exportSelection still exports in one go. @@ -112,8 +112,9 @@ fn the_assets_tab_exports_the_checked_assets_into_the_folder() { set(&mut app, "assets", json!([ids[1], 999])); set(&mut app, "formats", json!([{"format": "png", "scale": "2x", "suffix": "@2x"}])); let r = confirm(&mut app).unwrap(); - assert_eq!(r["files"], json!([format!("{folder}/Asset-2@2x.png")])); - let png = image::load_from_memory(&std::fs::read(dir.join("Asset-2@2x.png")).unwrap()).unwrap(); + // Create Sub-folders is on by default: a folder per scale. + assert_eq!(r["files"], json!([format!("{folder}/2x/Asset-2@2x.png")])); + let png = image::load_from_memory(&std::fs::read(dir.join("2x").join("Asset-2@2x.png")).unwrap()).unwrap(); assert_eq!((png.width(), png.height()), (60, 40), "the asset's art at 2x"); assert_eq!(log.borrow().len(), 1, "Open Location shows the file"); // The rows are remembered for the dialog and the Asset Export panel alike. diff --git a/crates/ui-egui/src/dialogs/tests_screens.rs b/crates/ui-egui/src/dialogs/tests_screens.rs index 38f469e30b..2a54850ca4 100644 --- a/crates/ui-egui/src/dialogs/tests_screens.rs +++ b/crates/ui-egui/src/dialogs/tests_screens.rs @@ -63,7 +63,8 @@ fn opens_on_defaults_and_draws_every_view() { assert_eq!((field(&app, "select"), field(&app, "range"), field(&app, "boards")), (json!("all"), json!("1-3"), json!([true, true, true]))); assert_eq!(field(&app, "formats"), json!([{"format": "png", "scale": "1x", "suffix": ""}])); assert_eq!(field(&app, "folder"), json!(fileio::export_folder().unwrap_or_default()), "the Desktop, else home"); - assert_eq!((field(&app, "openLocation"), field(&app, "subfolders"), field(&app, "preset")), (json!(true), json!(false), json!(""))); + assert_eq!((field(&app, "openLocation"), field(&app, "subfolders"), field(&app, "preset")), (json!(true), json!(true), json!(""))); + assert_eq!((field(&app, "subfolderBy"), field(&app, "pdfs"), field(&app, "view")), (json!("scale"), json!("multiple"), json!("grid"))); let settings = field(&app, "settings"); for f in ["png", "png8", "jpg", "webp", "gif", "tiff", "svg", "pdf"] { assert!(settings[f].is_object(), "{f} has settings"); @@ -128,7 +129,8 @@ fn opens_with_the_artboards_the_artboard_tool_selected_and_exports_only_them() { frame(&mut app); assert_eq!(field(&app, "boards"), json!([false, true, true, false]), "drawing the dialog keeps them"); let r = confirm(&mut app).unwrap(); - assert_eq!(r["files"], json!(["Artboard-2.png", "Artboard-3.png"])); + // Create Sub-folders is on by default, a folder per scale. + assert_eq!(r["files"], json!(["1x/Artboard-2.png", "1x/Artboard-3.png"])); assert_eq!(log.borrow().len(), 1, "one zip"); // With every artboard selected it opens on All. click(&mut app, 0, true); @@ -433,7 +435,7 @@ fn the_web_downloads_one_zip_or_the_file() { assert_eq!(name, "Untitled-1.zip"); assert_eq!(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(), zip); } - assert_eq!(r["files"], json!(["Artboard-1.png", "Artboard-1@2x.jpg", "Artboard-2.png", "Artboard-2@2x.jpg"])); + assert_eq!(r["files"], json!(["1x/Artboard-1.png", "2x/Artboard-1@2x.jpg", "1x/Artboard-2.png", "2x/Artboard-2@2x.jpg"])); // One file downloads as itself, with the format's settings. log.borrow_mut().clear(); open(&mut app); @@ -602,7 +604,7 @@ fn screen_format_gear(out: &egui::FullOutput, width: f32) -> egui::Pos2 { } } let label = out.shapes.iter().find_map(|s| label(&s.shape)).expect("painted Formats row"); - // The existing 24 pt gear is right-aligned in the 350 pt dialog or 236 pt panel. + // The existing 24 pt gear is right-aligned in the dialog's 370 pt column or the 236 pt panel. egui::pos2(label.left() + width - 12.0, label.center().y) } @@ -635,7 +637,7 @@ fn saved_tiff_alias_gear(alias: &str, asset_panel: bool) { }; draw(&mut h, &mut app, vec![]); let out = draw(&mut h, &mut app, vec![]); - let gear = screen_format_gear(&out, if asset_panel { 236.0 } else { 350.0 }); + let gear = screen_format_gear(&out, if asset_panel { 236.0 } else { 370.0 }); draw(&mut h, &mut app, vec![egui::Event::PointerMoved(gear)]); for pressed in [true, false] { draw( @@ -707,11 +709,12 @@ fn tiff_dropdown_exports_scaled_artboards_with_options_and_reopens() { // Begin with an existing PNG row; TIFF must be chosen from the actual popup. set(&mut app, "formats", json!([{"format": "png", "scale": "2x", "suffix": "@2x"}])); set(&mut app, "prefix", json!("proof-")); + set(&mut app, "subfolders", json!(false)); let mut h = ScreensFrame::new(); h.choose(&mut app, "PNG", "TIFF"); assert_eq!(field(&app, "formats"), json!([{"format": "tiff", "scale": "2x", "suffix": "@2x"}])); // Leave only the first of two boards checked. - h.click_label(&mut app, "Artboard 2"); + h.click_label(&mut app, "2 Artboard 2"); assert_eq!(field(&app, "boards"), json!([true, false])); assert_eq!(field(&app, "range"), json!("1")); @@ -766,168 +769,114 @@ fn tiff_dropdown_exports_scaled_artboards_with_options_and_reopens() { /// name, a prefix with characters Windows does not allow, a range, a Windows device name as an item /// folder, the rows' folders and sizes, Create Sub-folders, a vector row's size suffix, the counter /// typed as text, and the Layers and Selection tabs. +/// The dialog is laid out as Illustrator's: two tabs (Artboards, Assets), Clear Selection and the +/// prefix under the thumbnails, Create Sub-folders by scale or format, Export PDFs As, the iOS and +/// Android rows, and how many items and files the export takes; no naming, layer or selection +/// pages, presets bar or folders list. #[test] -fn the_example_names_the_first_file_the_export_writes() { +fn the_dialog_is_laid_out_as_illustrators() { let (mut app, _) = app(3, false); - app.run("artboard.setProps", json!({"index": 2, "name": "CON"})).unwrap(); - app.run("select.all", json!({})).unwrap(); - let dir = temp_folder("example"); - let folder = dir.to_string_lossy().to_string(); - let png = json!([{"format": "png", "scale": "1x", "suffix": ""}]); - let cases: Vec<Vec<(&str, Value)>> = vec![ - vec![], - vec![("prefix", json!("a?b"))], - vec![("select", json!("range")), ("range", json!("2-3"))], - vec![("select", json!("range")), ("range", json!("3")), ("folders", json!("item"))], - vec![("folders", json!("rows")), ("formats", json!([{"format": "png", "scale": "144ppi", "suffix": ""}]))], - vec![("subfolders", json!(true))], - vec![("folders", json!("rows")), ("formats", json!([{"format": "png", "scale": "2x", "suffix": "@2x", "folder": "retina"}]))], - vec![("formats", json!([{"format": "svg", "scale": "2x", "suffix": "@2x"}]))], - vec![("name", json!("<scale>-####")), ("counterStart", json!("7")), ("formats", json!([{"format": "png", "scale": "144ppi", "suffix": ""}]))], - vec![("tab", json!("layers"))], - vec![("tab", json!("selection")), ("selectionMode", json!("each"))], - ]; - let mut wrong = vec![]; - for case in cases { - let _ = std::fs::remove_dir_all(&dir); - open(&mut app); - let defaults = [ - ("folder", json!(folder)), - ("tab", json!("artboards")), - ("select", json!("all")), - ("prefix", json!("")), - ("name", json!("")), - ("counterStart", json!(1)), - ("folders", json!("none")), - ("subfolders", json!(false)), - ("formats", png.clone()), - ]; - for (k, v) in defaults.into_iter().chain(case.iter().cloned()) { - set(&mut app, k, v); - } - frame(&mut app); - let example = export_for_screens::example_name(&app, app.ui.dialog.as_ref().unwrap()); - let r = confirm(&mut app).unwrap(); - let first = r["files"][0].as_str().and_then(|f| f.strip_prefix(&format!("{folder}/"))).unwrap_or_default().to_string(); - if example != first { - wrong.push(format!("{case:?}: example {example:?}, written {first:?}")); - } + open(&mut app); + let text = crate::tests_labels::shapes_text(&ScreensFrame::new().settle(&mut app)); + for label in [ + "Artboards", + "Assets", + "Select:", + "Full Document", + "Include Bleed", + "Export to:", + "Open Location after Export", + "Create Sub-folders", + "Scale", + "Format", + "Export PDFs As:", + "Single File", + "Multiple Files", + "Formats:", + "iOS", + "Android", + "+ Add Scale", + "Clear Selection", + "Prefix:", + "Selected: 3, Total number of files to export: 3", + ] { + assert!(text.lines().any(|l| l.trim() == label), "{label} missing:\n{text}"); + } + for gone in ["Layers", "Selection", "Export Preset:", "File Name:", "Folders:", "Start at:"] { + assert!(!text.lines().any(|l| l.trim() == gone), "{gone} shown:\n{text}"); } - let _ = std::fs::remove_dir_all(&dir); - assert!(wrong.is_empty(), "{wrong:#?}"); } -/// The Example line shows before a folder is chosen and is left out while the settings choose -/// nothing to export (the Assets tab with no asset checked); Start at shows the counter the export -/// uses when it was set as text. +/// iOS and Android set the rows those platforms use (in sub-folders); Clear Selection unchecks +/// every artboard, so nothing is exported until one is checked again. #[test] -fn the_example_needs_no_folder_and_start_at_shows_the_counter_used() { - let (mut app, _) = app(1, false); +fn ios_android_and_clear_selection() { + let (mut app, _) = app(2, false); open(&mut app); - for (k, v) in [("folder", json!("")), ("name", json!("####")), ("counterStart", json!("7"))] { + set(&mut app, "subfolders", json!(false)); + let mut h = ScreensFrame::new(); + h.click_label(&mut app, "iOS"); + let scales: Vec<Value> = field(&app, "formats").as_array().unwrap().iter().map(|r| r["scale"].clone()).collect(); + assert_eq!((field(&app, "preset"), field(&app, "subfolders")), (json!("mobile"), json!(true))); + assert_eq!(scales, [json!("1x"), json!("2x"), json!("3x")]); + h.click_label(&mut app, "Android"); + assert_eq!((field(&app, "preset"), field(&app, "formats").as_array().unwrap().len()), (json!("density"), 6)); + h.click_label(&mut app, "Clear Selection"); + assert_eq!((field(&app, "boards"), field(&app, "select")), (json!([false, false]), json!("range"))); + let text = crate::tests_labels::shapes_text(&h.settle(&mut app)); + assert!(text.contains("Selected: 0, Total number of files to export: 0"), "{text}"); + set(&mut app, "folder", json!(temp_folder("cleared").to_string_lossy())); + assert!(confirm(&mut app).is_err(), "nothing to export"); +} + +/// Create Sub-folders sorts the files into a folder per scale or per format; Export PDFs As › +/// Single file writes the chosen artboards as one PDF named after the document. +#[test] +fn sub_folders_and_single_file_pdfs() { + let (mut app, _) = app(3, false); + let dir = temp_folder("subfolders"); + let _ = std::fs::remove_dir_all(&dir); + let folder = dir.to_string_lossy().to_string(); + let title = app.session.active().unwrap().doc.title.clone(); + let rows = json!([ + {"format": "png", "scale": "1x", "suffix": ""}, + {"format": "png", "scale": "2x", "suffix": "@2x"}, + {"format": "pdf", "scale": "1x", "suffix": ""} + ]); + let files = |app: &mut VectorcraftApp, extra: &[(&str, Value)]| -> Vec<String> { + open(app); + let base = [("folder", json!(folder)), ("select", json!("range")), ("range", json!("1-2")), ("formats", rows.clone())]; + for (k, v) in base.into_iter().chain(extra.iter().cloned()) { + set(app, k, v); + } + frame(app); + let r = confirm(app).unwrap(); + r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().strip_prefix(&format!("{folder}/")).unwrap().to_string()).collect() + }; + let by_scale = files(&mut app, &[("subfolders", json!(true)), ("subfolderBy", json!("scale"))]); + assert_eq!(&by_scale[..2], ["1x/Artboard-1.png", "2x/Artboard-1@2x.png"]); + let by_format = files(&mut app, &[("subfolders", json!(true)), ("subfolderBy", json!("format")), ("pdfs", json!("single"))]); + let pdf = format!("pdf/{title}.pdf"); + assert_eq!(by_format, ["png/Artboard-1.png", "png/Artboard-1@2x.png", pdf.as_str(), "png/Artboard-2.png", "png/Artboard-2@2x.png"]); + let flat = files(&mut app, &[("subfolders", json!(false)), ("pdfs", json!("multiple"))]); + assert_eq!(flat.len(), 6); + assert!(flat.contains(&"Artboard-2.pdf".to_string()), "{flat:?}"); + // The status line counts the files Single file writes. + open(&mut app); + for (k, v) in [("select", json!("range")), ("range", json!("1-2")), ("formats", rows.clone()), ("pdfs", json!("single"))] { set(&mut app, k, v); } - let mut h = ScreensFrame::new(); - let text = crate::tests_labels::shapes_text(&h.settle(&mut app)); - assert!(text.lines().any(|l| l == "Example: 0007.png"), "{text}"); - assert!(text.lines().any(|l| l.trim() == "7"), "{text}"); - set(&mut app, "tab", json!("assets")); - let text = crate::tests_labels::shapes_text(&h.settle(&mut app)); - assert!(!text.contains("Example:"), "{text}"); -} - -/// Batch exporting (#1935): a file name pattern and a folder per format, the Layers and Selection -/// tabs, and export presets applied and saved from the dialog. -#[test] -fn batch_naming_folders_layers_selection_and_presets() { - let (mut app, _) = app(3, false); - let dir = temp_folder("batch"); - let _ = std::fs::remove_dir_all(&dir); - let folder = dir.to_string_lossy().to_string(); - open(&mut app); - assert_eq!(field(&app, "folders"), json!("none")); - // Every tab draws; the OK button says what is exported. - for (tab, ok) in [("layers", "Export Layer"), ("selection", "Export Selection"), ("assets", "Export Asset"), ("artboards", "Export Artboard")] { - set(&mut app, "tab", json!(tab)); - frame(&mut app); - assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), ok); - } - // Artboards 1–2 as SVG and print PDF, named by pattern, a folder per format. - set(&mut app, "folder", json!(folder)); - set(&mut app, "select", json!("range")); - set(&mut app, "range", json!("1-2")); - set(&mut app, "name", json!("ACME-<name>-##")); - set(&mut app, "folders", json!("format")); - set( - &mut app, - "formats", - json!([{"format": "svg", "scale": "1x", "suffix": ""}, {"format": "pdf", "scale": "1x", "suffix": "-print", "marks": {"trim": true}}]), - ); - frame(&mut app); - { - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!(export_for_screens::example_name(&app, d), "svg/ACME-Artboard 1-01.svg"); - } - let r = confirm(&mut app).unwrap(); - let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect(); - assert_eq!( - files, - [ - format!("{folder}/svg/ACME-Artboard 1-01.svg"), - format!("{folder}/pdf/ACME-Artboard 1-01-print.pdf"), - format!("{folder}/svg/ACME-Artboard 2-02.svg"), - format!("{folder}/pdf/ACME-Artboard 2-02-print.pdf") - ] - ); - // It reopens on the pattern and the folders. - open(&mut app); - assert_eq!((field(&app, "name"), field(&app, "folders")), (json!("ACME-<name>-##"), json!("format"))); - - // The Layers tab: one file of a layer's art. - let layer = app.session.active().unwrap().doc.layers[0].id.0; - set(&mut app, "folder", json!(folder)); - set(&mut app, "tab", json!("layers")); - set(&mut app, "layers", json!([layer])); - set(&mut app, "name", json!("")); - set(&mut app, "folders", json!("none")); - set(&mut app, "formats", json!([{"format": "png", "scale": "1x", "suffix": ""}])); - let r = confirm(&mut app).unwrap(); - assert_eq!(r["files"][0], json!(format!("{folder}/Layer-1.png"))); - - // The Selection tab: nothing selected keeps the dialog open; a file per selected object. - open(&mut app); - app.run("select.none", json!({})).unwrap(); - set(&mut app, "folder", json!(folder)); - set(&mut app, "tab", json!("selection")); - frame(&mut app); - assert!(confirm(&mut app).is_err()); - app.run("select.all", json!({})).unwrap(); - set(&mut app, "selectionMode", json!("each")); - set(&mut app, "name", json!("piece-#")); - set(&mut app, "formats", json!([{"format": "svg", "scale": "1x", "suffix": ""}])); - frame(&mut app); - let r = confirm(&mut app).unwrap(); - assert_eq!(r["files"][0], json!(format!("{folder}/piece-1.svg"))); - - // Presets: the Logo Package sets the formats, naming and folders; the dialog saves its own. - open(&mut app); + let text = crate::tests_labels::shapes_text(&ScreensFrame::new().settle(&mut app)); + assert!(text.contains("Selected: 2, Total number of files to export: 5"), "{text}"); + // A format preset sets the rows, their settings and the sub-folders, not a prefix it lacks. + set(&mut app, "prefix", json!("x-")); let presets = field(&app, "__exportPresets"); let logo = presets.as_array().unwrap().iter().find(|p| p["name"] == "Logo Package").unwrap().clone(); export_for_screens::apply_preset(app.ui.dialog.as_mut().unwrap(), &logo); assert_eq!(field(&app, "formats").as_array().unwrap().len(), 5); assert_eq!( - (field(&app, "folders"), field(&app, "name"), field(&app, "exportPreset")), - (json!("format"), json!("<filename>-<name>"), json!("Logo Package")) + (field(&app, "subfolders"), field(&app, "subfolderBy"), field(&app, "exportPreset")), + (json!(true), json!("format"), json!("Logo Package")) ); - frame(&mut app); - set(&mut app, "folder", json!(folder)); - set(&mut app, "__savePreset", json!("Client Pack")); - frame(&mut app); - let saved = app - .run("document.exportPresets.save", json!({"name": "Client Pack", "settings": {"formats": field(&app, "formats"), "folders": "item"}})) - .unwrap(); - assert_eq!(saved["name"], "Client Pack"); - let list = export_for_screens::export_presets(&mut app); - assert!(list.as_array().unwrap().iter().any(|p| p["name"] == "Client Pack" && p["builtIn"] == false)); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 2cf931a8aa..99f0bbc2d8 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -649,6 +649,13 @@ font-technology Variable Променливи Create Report Създаване на отчет Create Slice Създаване на отрязък Create Sub-folders Създаване на подпапки + Clear Selection Изчистване на избора + Export PDFs As: Експортиране на PDF като: + Single File Един файл + Multiple Files Няколко файла + Format Presets Предварителни настройки на формата + Grid View Изглед на мрежа + Selected: {n}, Total number of files to export: {m} Избрани: {n}, общ брой файлове за експортиране: {m} Create Text Създаване на текст Create Trim Marks Създаване на режещи знаци Created Създадено @@ -3952,6 +3959,7 @@ axis Both И двете @msg cannot split an open path at its end point отворен път не може да бъде разделен в крайната му точка @msg closing needs three anchors затварянето изисква три опорни точки @msg check an asset to export изберете актив за експортиране +@msg check an artboard to export изберете монтажна област за експортиране @msg check a layer to export отметнете слой за експортиране @msg select the art to export first първо изберете графиката за експортиране @msg choice must be "merge" or "add", not `{choice}` изборът трябва да бъде "merge" или "add", а не `{choice}` diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 17de837b8e..700fd9ddee 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -2228,6 +2228,13 @@ lighting Light Svjetlo Use Artboards Koristi radne ploče Choose a folder Odaberite folder Create Sub-folders Kreiraj podfoldere + Clear Selection Poništi odabir + Export PDFs As: Izvezi PDF kao: + Single File Jedna datoteka + Multiple Files Više datoteka + Format Presets Postavke formata + Grid View Prikaz mreže + Selected: {n}, Total number of files to export: {m} Odabrano: {n}, ukupan broj datoteka za izvoz: {m} Export Asset Izvezi resurs Export to: Izvezi u: Format Settings Postavke formata @@ -4339,6 +4346,7 @@ imageTrace Outline Obris @msg cannot split an open path at its end point nije moguće razdvojiti otvorenu putanju na njenoj krajnjoj tački @msg closing needs three anchors zatvaranje zahtijeva tri sidra @msg check an asset to export označite resurs za izvoz +@msg check an artboard to export označite radnu površinu za izvoz @msg check a layer to export označite sloj za izvoz @msg select the art to export first prvo odaberite crtež za izvoz @msg choice must be "merge" or "add", not `{choice}` choice mora biti "merge" ili "add", a ne `{choice}` diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index a9adf22ae1..dc8fc7effd 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -918,6 +918,13 @@ Create Slice from Selection Crea un tall a partir de la selecció Create Slices from Guides Crea talls a partir de guies Create Sub-folders Crea subcarpetes + Clear Selection Esborra la selecció + Export PDFs As: Exporta els PDF com a: + Single File Un sol fitxer + Multiple Files Diversos fitxers + Format Presets Preajustos de format + Grid View Vista de quadrícula + Selected: {n}, Total number of files to export: {m} Seleccionats: {n}, nombre total de fitxers per exportar: {m} Create Text Crea text Create Trim Marks Crea marques de retall Create: Crea: @@ -4885,6 +4892,7 @@ @msg cannot split an open path at its end point no es pot dividir un traçat obert al seu punt final @msg check a layer to export marqueu una capa per exportar @msg check an asset to export marqueu un recurs per exportar +@msg check an artboard to export marqueu una mesa de treball per exportar @msg choice must be "merge" or "add", not `{choice}` l’opció ha de ser "merge" o "add", no `{choice}` @msg Choose a circle or a perfect arc Trieu un cercle o un arc perfecte @msg choose a destination folder trieu una carpeta de destinació diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 41d9b25f8f..5498e35481 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2257,6 +2257,13 @@ lighting Light Světlo Use Artboards Použít pracovní plochy Choose a folder Vyberte složku Create Sub-folders Vytvořit podsložky + Clear Selection Vymazat výběr + Export PDFs As: Exportovat PDF jako: + Single File Jeden soubor + Multiple Files Více souborů + Format Presets Přednastavení formátů + Grid View Zobrazení mřížky + Selected: {n}, Total number of files to export: {m} Vybráno: {n}, celkový počet souborů k exportu: {m} Export Asset Exportovat prvek Export to: Exportovat do: Format Settings Nastavení formátu @@ -4375,6 +4382,7 @@ imageTrace Outline Obrys @msg cannot split an open path at its end point otevřenou cestu nelze rozdělit v jejím koncovém bodu @msg closing needs three anchors uzavření vyžaduje tři kotevní body @msg check an asset to export zaškrtněte prvek k exportu +@msg check an artboard to export zaškrtněte kreslicí plátno k exportu @msg check a layer to export zaškrtněte vrstvu k exportu @msg select the art to export first nejprve vyberte kresbu k exportu @msg choice must be "merge" or "add", not `{choice}` volba musí být "merge" nebo "add", ne `{choice}` diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 1b6277fd59..c4e19d9939 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2061,6 +2061,13 @@ lighting Light Lys Use Artboards Brug tegnebrætter Choose a folder Vælg en mappe Create Sub-folders Opret undermapper + Clear Selection Ryd markering + Export PDFs As: Eksportér PDF'er som: + Single File Én fil + Multiple Files Flere filer + Format Presets Formatforudindstillinger + Grid View Gittervisning + Selected: {n}, Total number of files to export: {m} Valgt: {n}, samlet antal filer, der eksporteres: {m} Export Asset Eksportér aktiv Export to: Eksportér til: Format Settings Formatindstillinger @@ -4067,6 +4074,7 @@ imageTrace Outline Kontur @msg cannot split an open path at its end point kan ikke opdele en åben kurve i dens endepunkt @msg closing needs three anchors lukning kræver tre ankerpunkter @msg check an asset to export afkryds et aktiv, der skal eksporteres +@msg check an artboard to export afkryds en tegneflade, der skal eksporteres @msg choice must be "merge" or "add", not `{choice}` valget skal være "merge" eller "add", ikke `{choice}` @msg choose a direction to divide in vælg en retning at opdele i @msg choose a folder to export to vælg en mappe at eksportere til diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 405462042d..87ac4fc701 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -642,6 +642,13 @@ font-technology Variable Variabel Create Report Bericht erstellen Create Slice Slice erstellen Create Sub-folders Unterordner erstellen + Clear Selection Auswahl aufheben + Export PDFs As: PDFs exportieren als: + Single File Einzelne Datei + Multiple Files Mehrere Dateien + Format Presets Formatvorgaben + Grid View Rasteransicht + Selected: {n}, Total number of files to export: {m} Ausgewählt: {n}, Gesamtzahl der zu exportierenden Dateien: {m} Create Text Text erstellen Create Trim Marks Schnittmarken erstellen Created Erstellt @@ -3988,6 +3995,7 @@ theme Appearance Erscheinungsbild @msg cannot split an open path at its end point ein offener Pfad kann nicht an seinem Endpunkt geteilt werden @msg closing needs three anchors zum Schließen sind drei Ankerpunkte nötig @msg check an asset to export markieren Sie ein Asset für den Export +@msg check an artboard to export markieren Sie eine Zeichenfläche für den Export @msg check a layer to export wählen Sie eine Ebene für den Export @msg select the art to export first wählen Sie zuerst das zu exportierende Bildmaterial aus @msg choice must be "merge" or "add", not `{choice}` choice muss "merge" oder "add" sein, nicht `{choice}` diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 5147b4fd94..c7c2ab8980 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -623,6 +623,13 @@ font-technology Variable Μεταβλητές Create Report Δημιουργία αναφοράς Create Slice Δημιουργία τομής Create Sub-folders Δημιουργία υποφακέλων + Clear Selection Απαλοιφή επιλογής + Export PDFs As: Εξαγωγή PDF ως: + Single File Ένα αρχείο + Multiple Files Πολλά αρχεία + Format Presets Προεπιλογές μορφής + Grid View Προβολή πλέγματος + Selected: {n}, Total number of files to export: {m} Επιλεγμένα: {n}, συνολικός αριθμός αρχείων για εξαγωγή: {m} Create Text Δημιουργία κειμένου Create Trim Marks Δημιουργία σημαδιών ξακρίσματος Created Δημιουργήθηκε @@ -4028,6 +4035,7 @@ theme Appearance Εμφάνιση @msg cannot split an open path at its end point δεν είναι δυνατή η διαίρεση ανοιχτής διαδρομής στο τελικό σημείο της @msg closing needs three anchors το κλείσιμο απαιτεί τρία σημεία αγκύρωσης @msg check an asset to export επιλέξτε στοιχείο για εξαγωγή +@msg check an artboard to export επιλέξτε πινακίδα σχεδίασης για εξαγωγή @msg check a layer to export επιλέξτε ένα επίπεδο για εξαγωγή @msg select the art to export first επιλέξτε πρώτα το γραφικό για εξαγωγή @msg choice must be "merge" or "add", not `{choice}` η επιλογή πρέπει να είναι "merge" ή "add", όχι `{choice}` diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index bbd74c412b..b517c4c791 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -663,6 +663,13 @@ font-technology Variable Variables Create Report Crear informe Create Slice Crear sector Create Sub-folders Crear subcarpetas + Clear Selection Borrar selección + Export PDFs As: Exportar PDF como: + Single File Un solo archivo + Multiple Files Varios archivos + Format Presets Preajustes de formato + Grid View Vista de cuadrícula + Selected: {n}, Total number of files to export: {m} Seleccionados: {n}, número total de archivos para exportar: {m} Create Text Crear texto Create Trim Marks Crear marcas de recorte Created Creado @@ -4068,6 +4075,7 @@ axis Both Ambos @msg cannot split an open path at its end point no se puede dividir un trazado abierto en su punto final @msg closing needs three anchors cerrar necesita tres puntos de ancla @msg check an asset to export marca un recurso para exportar +@msg check an artboard to export marca una mesa de trabajo para exportar @msg check a layer to export marque una capa para exportar @msg select the art to export first seleccione primero la ilustración que desea exportar @msg choice must be "merge" or "add", not `{choice}` choice debe ser "merge" o "add", no `{choice}` diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 27c96b5301..92f4e48945 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2213,6 +2213,13 @@ lighting Light Valo Use Artboards Käytä piirtoalustoja Choose a folder Valitse kansio Create Sub-folders Luo alikansiot + Clear Selection Tyhjennä valinta + Export PDFs As: Vie PDF-tiedostot muodossa: + Single File Yksi tiedosto + Multiple Files Useita tiedostoja + Format Presets Muotoesiasetukset + Grid View Ruudukkonäkymä + Selected: {n}, Total number of files to export: {m} Valittu: {n}, vietävien tiedostojen määrä yhteensä: {m} Export Asset Vie resurssi Export to: Vie kohteeseen: Format Settings Muotoasetukset @@ -4284,6 +4291,7 @@ imageTrace Outline Ääriviiva @msg cannot split an open path at its end point avointa polkua ei voi jakaa sen päätepisteestä @msg closing needs three anchors sulkeminen vaatii kolme ankkuria @msg check an asset to export valitse vietävä resurssi +@msg check an artboard to export valitse vietävä piirtoalue @msg check a layer to export valitse vietävä taso @msg select the art to export first valitse ensin vietävä kuvitus @msg choice must be "merge" or "add", not `{choice}` choice-arvon on oltava "merge" tai "add", ei `{choice}` diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index bbcab564e8..27a2d52010 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -665,6 +665,13 @@ font-technology Variable Variables Create Report Créer un rapport Create Slice Créer une tranche Create Sub-folders Créer des sous-dossiers + Clear Selection Effacer la sélection + Export PDFs As: Exporter les PDF sous : + Single File Un seul fichier + Multiple Files Plusieurs fichiers + Format Presets Paramètres prédéfinis de format + Grid View Vue en grille + Selected: {n}, Total number of files to export: {m} Sélectionnés : {n}, nombre total de fichiers à exporter : {m} Create Text Créer du texte Create Trim Marks Créer des traits de coupe Created Création @@ -4065,6 +4072,7 @@ axis Both Les deux @msg cannot split an open path at its end point impossible de scinder un tracé ouvert à son extrémité @msg closing needs three anchors la fermeture nécessite trois points d’ancrage @msg check an asset to export cochez une ressource à exporter +@msg check an artboard to export cochez un plan de travail à exporter @msg check a layer to export cochez un calque à exporter @msg select the art to export first sélectionnez d’abord l’illustration à exporter @msg choice must be "merge" or "add", not `{choice}` choice doit valoir "merge" ou "add", pas `{choice}` diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a17b4724fb..ec63473054 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -859,6 +859,13 @@ font-technology Variable Változó Create Report Jelentés készítése Create Slice Szelet létrehozása Create Sub-folders Almappák létrehozása + Clear Selection Kijelölés törlése + Export PDFs As: PDF-ek exportálása: + Single File Egy fájlba + Multiple Files Több fájlba + Format Presets Formátum-előbeállítások + Grid View Rácsnézet + Selected: {n}, Total number of files to export: {m} Kijelölve: {n}, exportálandó fájlok száma összesen: {m} Create Text Szöveg létrehozása Create Trim Marks Vágójelek létrehozása Create: Létrehozás: @@ -4624,6 +4631,7 @@ imageTrace Outline Körvonal @msg cannot read PDF: {0} a PDF nem olvasható: {0} @msg cannot split an open path at its end point nyitott útvonal a végpontjánál nem vágható ketté @msg check an asset to export jelölj be egy exportálandó elemet +@msg check an artboard to export jelölj be egy exportálandó rajztáblát @msg check a layer to export jelöljön be egy exportálandó réteget @msg select the art to export first először jelölje ki az exportálandó grafikát @msg choice must be "merge" or "add", not `{choice}` a choice értéke "merge" vagy "add" lehet, ez nem az: `{choice}` diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 85f09a6a6d..f958f41b14 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2061,6 +2061,13 @@ lighting Light Cahaya Use Artboards Gunakan artboard Choose a folder Pilih folder Create Sub-folders Buat subfolder + Clear Selection Hapus pilihan + Export PDFs As: Ekspor PDF sebagai: + Single File Satu file + Multiple Files Beberapa file + Format Presets Preset format + Grid View Tampilan kisi + Selected: {n}, Total number of files to export: {m} Dipilih: {n}, jumlah total file yang akan diekspor: {m} Export Asset Ekspor aset Export to: Ekspor ke: Format Settings Pengaturan format @@ -4067,6 +4074,7 @@ imageTrace Outline Kontur @msg cannot split an open path at its end point tidak dapat membelah jalur terbuka pada titik ujungnya @msg closing needs three anchors penutupan memerlukan tiga jangkar @msg check an asset to export centang aset yang akan diekspor +@msg check an artboard to export centang artboard yang akan diekspor @msg choice must be "merge" or "add", not `{choice}` choice harus "merge" atau "add", bukan `{choice}` @msg choose a direction to divide in pilih arah untuk membagi @msg choose a folder to export to pilih folder tujuan ekspor diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8be70bb1ff..a326cf55fb 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -663,6 +663,13 @@ font-technology Variable Variabili Create Report Crea rapporto Create Slice Crea sezione Create Sub-folders Crea sottocartelle + Clear Selection Cancella selezione + Export PDFs As: Esporta PDF come: + Single File File singolo + Multiple Files Più file + Format Presets Predefiniti formato + Grid View Vista griglia + Selected: {n}, Total number of files to export: {m} Selezionati: {n}, numero totale di file da esportare: {m} Create Text Crea testo Create Trim Marks Crea segni di taglio Created Creato @@ -4108,6 +4115,7 @@ axis Both Entrambi @msg cannot split an open path at its end point impossibile dividere un tracciato aperto nel suo punto finale @msg closing needs three anchors per chiudere servono tre punti di ancoraggio @msg check an asset to export spunta una risorsa da esportare +@msg check an artboard to export spunta una tavola da disegno da esportare @msg check a layer to export seleziona un livello da esportare @msg select the art to export first seleziona prima la grafica da esportare @msg choice must be "merge" or "add", not `{choice}` choice deve essere "merge" o "add", non `{choice}` diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c7acb2adf3..50afc7d72b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1231,6 +1231,13 @@ font-technology Variable バリアブル Create Path パスを作成 Create Slice スライスを作成 Create Sub-folders サブフォルダーを作成 + Clear Selection 選択を解除 + Export PDFs As: PDF の書き出し形式: + Single File 単一ファイル + Multiple Files 複数ファイル + Format Presets 形式プリセット + Grid View グリッド表示 + Selected: {n}, Total number of files to export: {m} 選択済み:{n}、書き出すファイルの合計数:{m} Create Text テキストを作成 Created 作成日 Created: 作成日: @@ -4425,6 +4432,7 @@ swatch Bright Violet ビビッドバイオレット @msg nothing to export: the document has no visible art (or turn on Use Artboards) 書き出すものがありません: ドキュメントに表示されているアートがありません (または「アートボードを使用」をオンにしてください) @msg add a format to export 書き出す形式を追加してください @msg check an asset to export 書き出すアセットにチェックを入れてください +@msg check an artboard to export 書き出すアートボードにチェックを入れてください @msg check a layer to export 書き出すレイヤーにチェックを入れてください @msg select the art to export first 先に書き出すアートを選択してください @msg `{tool}` isn't a freehand tool `{tool}` はフリーハンドツールではありません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 903d741c74..8b52357ad9 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1961,6 +1961,13 @@ lighting Light სინათლე Use Artboards სამუშაო დაფების გამოყენება Choose a folder საქაღალდის არჩევა Create Sub-folders ქვესაქაღალდეების შექმნა + Clear Selection მონიშვნის გასუფთავება + Export PDFs As: PDF-ების ექსპორტი როგორც: + Single File ერთი ფაილი + Multiple Files რამდენიმე ფაილი + Format Presets ფორმატის პრესეტები + Grid View ბადის ხედი + Selected: {n}, Total number of files to export: {m} არჩეულია: {n}, ექსპორტის ფაილების საერთო რაოდენობა: {m} Export Asset რესურსის ექსპორტირება Export to: ექსპორტის ადგილი: Format Settings ფორმატის პარამეტრები @@ -3851,6 +3858,7 @@ font-technology Variable ცვლადი @msg cannot split an open path at its end point ღია ტრაექტორიის ბოლო წერტილში გაყოფა შეუძლებელია @msg closing needs three anchors შეკვრას სამი საყრდენი წერტილი სჭირდება @msg check an asset to export მონიშნეთ ექსპორტის რესურსი +@msg check an artboard to export მონიშნეთ ექსპორტის სახატავი დაფა @msg choice must be "merge" or "add", not `{choice}` choice უნდა იყოს "merge" ან "add" და არა `{choice}` @msg choose a direction to divide in აირჩიეთ დაყოფის მიმართულება @msg choose a folder to export to აირჩიეთ ექსპორტის საქაღალდე diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 732af24f62..d1ebee17d3 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1222,6 +1222,13 @@ font-technology Variable 가변 Create Path 패스 만들기 Create Slice 슬라이스 만들기 Create Sub-folders 하위 폴더 만들기 + Clear Selection 선택 지우기 + Export PDFs As: PDF 내보내기 방식: + Single File 단일 파일 + Multiple Files 여러 파일 + Format Presets 형식 사전 설정 + Grid View 격자 보기 + Selected: {n}, Total number of files to export: {m} 선택됨: {n}, 내보낼 총 파일 수: {m} Create Text 텍스트 만들기 Created 만든 날짜 Created: 만든 날짜: @@ -4353,6 +4360,7 @@ theme Appearance 외관 @msg nothing to export: the document has no visible art (or turn on Use Artboards) 내보낼 항목이 없습니다: 문서에 보이는 아트웍이 없습니다(또는 아트보드 사용을 켜세요) @msg add a format to export 내보낼 형식을 추가하세요 @msg check an asset to export 내보낼 자산을 체크하세요 +@msg check an artboard to export 내보낼 대지를 체크하세요 @msg check a layer to export 내보낼 레이어를 선택하십시오 @msg select the art to export first 먼저 내보낼 아트를 선택하십시오 @msg `{tool}` isn't a freehand tool `{tool}`은(는) 프리핸드 도구가 아닙니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index effdca022d..334707edc5 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2075,6 +2075,13 @@ lighting Light Licht Use Artboards Tekenborden gebruiken Choose a folder Kies een map Create Sub-folders Submappen maken + Clear Selection Selectie wissen + Export PDFs As: PDF's exporteren als: + Single File Eén bestand + Multiple Files Meerdere bestanden + Format Presets Indelingsvoorinstellingen + Grid View Rasterweergave + Selected: {n}, Total number of files to export: {m} Geselecteerd: {n}, totaal aantal te exporteren bestanden: {m} Export Asset Asset exporteren Export to: Exporteren naar: Format Settings Indelingsinstellingen @@ -4081,6 +4088,7 @@ imageTrace Outline Contour @msg cannot split an open path at its end point kan een open pad niet splitsen op zijn eindpunt @msg closing needs three anchors voor sluiten zijn drie ankers nodig @msg check an asset to export vink een asset aan om te exporteren +@msg check an artboard to export vink een tekengebied aan om te exporteren @msg choice must be "merge" or "add", not `{choice}` choice moet "merge" of "add" zijn, niet `{choice}` @msg choose a direction to divide in kies een richting om in te verdelen @msg choose a folder to export to kies een map om naar te exporteren diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 6be694f622..49d549c17b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2174,6 +2174,13 @@ lighting Light Światło Use Artboards Użyj obszarów kompozycji Choose a folder Wybierz folder Create Sub-folders Utwórz podfoldery + Clear Selection Wyczyść zaznaczenie + Export PDFs As: Eksportuj pliki PDF jako: + Single File Jeden plik + Multiple Files Wiele plików + Format Presets Ustawienia formatów + Grid View Widok siatki + Selected: {n}, Total number of files to export: {m} Zaznaczono: {n}, łączna liczba plików do eksportu: {m} Export Asset Eksportuj zasób Export to: Eksportuj do: Format Settings Ustawienia formatu @@ -4199,6 +4206,7 @@ imageTrace Outline Kontur @msg cannot split an open path at its end point nie można podzielić otwartej ścieżki w jej punkcie końcowym @msg closing needs three anchors zamknięcie wymaga trzech punktów @msg check an asset to export zaznacz zasób do eksportu +@msg check an artboard to export zaznacz obszar kompozycji do eksportu @msg check a layer to export zaznacz warstwę do eksportu @msg select the art to export first najpierw zaznacz grafikę do eksportu @msg choice must be "merge" or "add", not `{choice}` wybór musi mieć wartość "merge" lub "add", nie `{choice}` diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 5fe9922eda..26d60b4234 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -585,6 +585,13 @@ Create Path Criar Caminho Create Slice Criar Fatia Create Sub-folders Criar Subpastas + Clear Selection Limpar Seleção + Export PDFs As: Exportar PDFs Como: + Single File Arquivo Único + Multiple Files Vários Arquivos + Format Presets Predefinições de Formato + Grid View Exibição em Grade + Selected: {n}, Total number of files to export: {m} Selecionados: {n}, número total de arquivos a exportar: {m} Create Text Criar Texto Create Trim Marks Criar Marcas de Corte Create from Guides Criar a partir de Guias @@ -3646,6 +3653,7 @@ axis Both Ambos @msg cannot split an open path at its end point não é possível dividir um traçado aberto em sua extremidade @msg closing needs three anchors fechar exige três âncoras @msg check an asset to export marque um recurso para exportar +@msg check an artboard to export marque uma prancheta para exportar @msg check a layer to export marque uma camada para exportar @msg select the art to export first selecione primeiro a arte a exportar @msg choice must be "merge" or "add", not `{choice}` choice deve ser "merge" ou "add", não `{choice}` diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index bd4faa713d..2fec550643 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -661,6 +661,13 @@ font-technology Variable Вариативные Create Report Создать отчёт Create Slice Создать фрагмент Create Sub-folders Создать вложенные папки + Clear Selection Снять выделение + Export PDFs As: Экспортировать PDF как: + Single File Один файл + Multiple Files Несколько файлов + Format Presets Наборы форматов + Grid View Сетка + Selected: {n}, Total number of files to export: {m} Выбрано: {n}, всего файлов для экспорта: {m} Create Text Создать текст Create Trim Marks Создать метки подрезки Created Создано @@ -4104,6 +4111,7 @@ axis Both Оба @msg cannot split an open path at its end point нельзя разделить открытый контур в его конечной точке @msg closing needs three anchors для замыкания нужны три опорные точки @msg check an asset to export отметьте ресурс для экспорта +@msg check an artboard to export отметьте монтажную область для экспорта @msg check a layer to export отметьте слой для экспорта @msg select the art to export first сначала выделите объекты для экспорта @msg choice must be "merge" or "add", not `{choice}` choice должно быть "merge" или "add", а не `{choice}` diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 273720935b..fd253ed7a9 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -651,6 +651,13 @@ font-technology Variable Variabla Create Report Skapa rapport Create Slice Skapa skiva Create Sub-folders Skapa undermappar + Clear Selection Rensa markering + Export PDFs As: Exportera PDF-filer som: + Single File En fil + Multiple Files Flera filer + Format Presets Formatförinställningar + Grid View Rutnätsvy + Selected: {n}, Total number of files to export: {m} Markerade: {n}, totalt antal filer att exportera: {m} Create Text Skapa text Create Trim Marks Skapa skärmärken Created Skapad @@ -3979,6 +3986,7 @@ axis Both Båda @msg cannot split an open path at its end point kan inte dela en öppen bana vid dess slutpunkt @msg closing needs three anchors att sluta banan kräver tre ankarpunkter @msg check an asset to export kryssa för en resurs att exportera +@msg check an artboard to export kryssa för en rityta att exportera @msg check a layer to export markera ett lager att exportera @msg select the art to export first markera först teckningen som ska exporteras @msg choice must be "merge" or "add", not `{choice}` choice måste vara "merge" eller "add", inte `{choice}` diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 180f3448da..849c9c74f5 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -643,6 +643,13 @@ font-technology Variable Değişken Create Report Rapor Oluştur Create Slice Dilim Oluştur Create Sub-folders Alt Klasörler Oluşturun + Clear Selection Seçimi Temizle + Export PDFs As: PDF'leri Şu Şekilde Dışa Aktar: + Single File Tek Dosya + Multiple Files Birden Fazla Dosya + Format Presets Biçim Ön Ayarları + Grid View Izgara Görünümü + Selected: {n}, Total number of files to export: {m} Seçilen: {n}, dışa aktarılacak toplam dosya sayısı: {m} Create Text Metin Oluştur Create Trim Marks Kesim İşaretleri Oluşturun Created Oluşturuldu @@ -3848,6 +3855,7 @@ axis Both Her İkisi @msg cannot split an open path at its end point açık bir yolu bitiş noktasında bölemez @msg closing needs three anchors Kapanış üç çapaya ihtiyaç duyar @msg check an asset to export dışa aktarılacak bir varlığı kontrol etme +@msg check an artboard to export dışa aktarılacak bir çalışma yüzeyi işaretleyin @msg check a layer to export dışa aktarılacak katmanı kontrol edin @msg select the art to export first önce dışa aktarılacak resmi seçin @msg choice must be "merge" or "add", not `{choice}` seçim "{choice}" değil "birleştir" veya "ekle" olmalıdır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4d267593f6..9f06a0f787 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -657,6 +657,13 @@ font-technology Variable Варіативні Create Report Створити звіт Create Slice Створити фрагмент Create Sub-folders Створити вкладені папки + Clear Selection Зняти виділення + Export PDFs As: Експортувати PDF як: + Single File Один файл + Multiple Files Кілька файлів + Format Presets Набори форматів + Grid View Сітка + Selected: {n}, Total number of files to export: {m} Вибрано: {n}, усього файлів для експорту: {m} Create Text Створити текст Create Trim Marks Створити мітки підрізування Created Створено @@ -4092,6 +4099,7 @@ axis Both Обидві @msg cannot split an open path at its end point не можна розділити відкритий контур у його кінцевій точці @msg closing needs three anchors для замикання потрібні три опорні точки @msg check an asset to export позначте ресурс для експорту +@msg check an artboard to export позначте монтажну область для експорту @msg check a layer to export позначте шар для експорту @msg select the art to export first спочатку виділіть об’єкти для експорту @msg choice must be "merge" or "add", not `{choice}` choice має бути "merge" або "add", а не `{choice}` diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 67169df4de..814b2bd0d8 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -760,6 +760,13 @@ font-technology Variable Biến thiên Create Report Tạo báo cáo Create Slice Tạo lát cắt Create Sub-folders Tạo thư mục con + Clear Selection Xóa lựa chọn + Export PDFs As: Xuất PDF dưới dạng: + Single File Một tệp + Multiple Files Nhiều tệp + Format Presets Thiết lập sẵn định dạng + Grid View Chế độ lưới + Selected: {n}, Total number of files to export: {m} Đã chọn: {n}, tổng số tệp sẽ xuất: {m} Create Text Tạo văn bản Create Trim Marks Tạo dấu cắt xén Create: Tạo: @@ -4274,6 +4281,7 @@ theme Appearance Giao diện @msg cannot read PDF: {0} không thể đọc PDF: {0} @msg cannot split an open path at its end point không thể tách đường dẫn mở tại điểm cuối @msg check an asset to export hãy chọn một asset để xuất +@msg check an artboard to export hãy chọn một bảng vẽ để xuất @msg check a layer to export hãy đánh dấu một lớp để xuất @msg select the art to export first hãy chọn hình cần xuất trước @msg choice must be "merge" or "add", not `{choice}` lựa chọn phải là "merge" hoặc "add", không phải `{choice}` diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 6da63132e7..8fa91dcae0 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -608,6 +608,13 @@ Create Report 创建报告 Create Slice 创建切片 Create Sub-folders 创建子文件夹 + Clear Selection 清除选择 + Export PDFs As: PDF 导出为: + Single File 单个文件 + Multiple Files 多个文件 + Format Presets 格式预设 + Grid View 网格视图 + Selected: {n}, Total number of files to export: {m} 已选择:{n},要导出的文件总数:{m} Create Text 创建文字 Create Trim Marks 创建裁切标记 Created 创建时间 @@ -4148,6 +4155,7 @@ imageTrace Outline 轮廓 @msg nothing to export: the document has no visible art (or turn on Use Artboards) 没有可导出的内容:文档中没有可见的图稿 (或请打开“使用画板”) @msg add a format to export 请添加要导出的格式 @msg check an asset to export 请勾选要导出的资源 +@msg check an artboard to export 请勾选要导出的画板 @msg `{tool}` isn't a freehand tool `{tool}` 不是手绘工具 @msg bad colour `{hex}` (#rrggbb) 颜色 `{hex}` 无效 (#rrggbb) @msg no gradient stop or freeform point is selected 未选择渐变色标或任意形状点 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 7e21abda50..8525483188 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -625,6 +625,13 @@ font-technology Variable 可變 Create Report 建立報告 Create Slice 建立切片 Create Sub-folders 建立子資料夾 + Clear Selection 清除選取 + Export PDFs As: PDF 轉存為: + Single File 單一檔案 + Multiple Files 多個檔案 + Format Presets 格式預設集 + Grid View 格點檢視 + Selected: {n}, Total number of files to export: {m} 已選取:{n},要轉存的檔案總數:{m} Create Text 建立文字 Create Trim Marks 建立剪裁標記 Created 建立時間 @@ -2088,6 +2095,7 @@ file-filter Templates 範本 @msg nothing to export: the document has no visible art (or turn on Use Artboards) 沒有可匯出的內容:文件中沒有可見的圖稿 (或請開啟「使用工作區域」) @msg add a format to export 請新增要匯出的格式 @msg check an asset to export 請勾選要匯出的資產 +@msg check an artboard to export 請勾選要匯出的工作區域 @msg `{tool}` isn't a freehand tool `{tool}` 不是手繪工具 @msg bad colour `{hex}` (#rrggbb) 顏色 `{hex}` 無效 (#rrggbb) @msg no gradient stop or freeform point is selected 未選取漸層色標或任意形狀點 diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index d7bb709771..9e463f49e9 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -285,6 +285,8 @@ fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, open_locati } app.status(status); } else if let Some(download) = app.services.download.as_mut() { + // A browser download has no folders: a file from a sub-folder (`2x/Logo@2x.png`) is named alone. + let returned: Vec<(&str, &str)> = returned.iter().map(|(name, data)| (name.rsplit('/').next().unwrap_or(name), *data)).collect(); for (name, data) in &returned { let bytes = vectorcraft_format::base64_decode(data).ok_or("the export returned unreadable data")?; download(name, &bytes)?; diff --git a/crates/ui-egui/src/panels/asset_export.rs b/crates/ui-egui/src/panels/asset_export.rs index 3cc81fce7d..b9b620ac0f 100644 --- a/crates/ui-egui/src/panels/asset_export.rs +++ b/crates/ui-egui/src/panels/asset_export.rs @@ -263,7 +263,7 @@ fn settings(ui: &mut Ui, st: &DocState) -> (Option<Settings>, usize) { let preset = preset.unwrap_or_default().to_string(); let n = rows.len(); let mut d = Dialog::new(EXPORT_FOR_SCREENS, json!({ "formats": &rows, "preset": preset, "__settings": "" })); - screen_formats(ui, &mut d); + screen_formats(ui, &mut d, None); let (shown, picked) = (d.str("__settings"), d.str("preset")); let formats = d.fields.remove("formats").unwrap_or(Value::Null); let change = if !shown.is_empty() { diff --git a/crates/ui-egui/src/panels/tests_asset_export.rs b/crates/ui-egui/src/panels/tests_asset_export.rs index b3d29e2f4c..aac9cabc1d 100644 --- a/crates/ui-egui/src/panels/tests_asset_export.rs +++ b/crates/ui-egui/src/panels/tests_asset_export.rs @@ -209,7 +209,7 @@ fn the_rows_are_the_shared_export_settings() { // A preset set by an agent shows as its rows. app.run("assets.settings.set", json!({"preset": "mobile"})).unwrap(); let texts = frame(&ctx, &mut app, vec![], false); - assert!(has(&texts, "Mobile 1x/2x/3x") && has(&texts, "@3x")); + assert!(has(&texts, "iOS") && has(&texts, "@3x")); // Export (nothing selected: every asset) downloads one zip on the web. let texts = frame(&ctx, &mut app, vec![], false); frame(&ctx, &mut app, click(at(&texts, "Export")), false); From 46e165731c7b9a24320e8f9fb47e057ab757bfbc Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Sat, 31 Oct 2026 21:41:10 +0700 Subject: [PATCH 92/98] Docs: Export for Screens laid out as Illustrator's --- docs/target-app-parity.md | 5 +++-- docs/ui-parity.md | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8d930ef07e..38d9ba8b6b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Artwork Cleanup add-on: Join Point Text, #4826; scores unchanged; previously: G13: Direct Selection snaps a dragged line end square to straight paths and grazing curves; Path Precision Tools add-on: Shared Edge Merge Tool; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Export for Screens laid out as Illustrator's, with Export PDFs As and sub-folders by scale or format; previously: Artwork Cleanup add-on: Join Point Text, #4826; scores unchanged; previously: G13: Direct Selection snaps a dragged line end square to straight paths and grazing curves; Path Precision Tools add-on: Shared Edge Merge Tool; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -19,6 +19,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Export for Screens is laid out as Illustrator's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged. | | 2026-10-31 | minor | Artwork Cleanup add-on: Join Point Text (`addon.cleanup.joinText`) joins stacked one-line point type into one object, a paragraph per line keeping its baseline (#4826); scores unchanged. | | 2026-10-31 | minor | Width tool: a stroke with a width profile keeps its shape while the view zooms out; the minimum visible width goes to its widest point instead of scaling the whole profile (#995); scores unchanged. | | 2026-10-31 | minor | Patterns: Grid Repeat Options has a Preview checkbox, as the public grid options page lists (#2651); scores unchanged. | @@ -542,7 +543,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Batch…: Override Save and Override Export name each file with the extension of the format the step writes, however the step spells the format (`.svg` and `SVG` give `.svg`, `png8` gives `.png`), in `action.batch`. - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. - - 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 for Screens is laid out as Illustrator'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, 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; every command, like the rest, is drivable over MCP and the CLI. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f32db08429..afd2fac04d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Properties: Blend Options in Quick Actions for one selected blend with any tool, #4817; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Export for Screens laid out as Illustrator's; previously: Properties: Blend Options in Quick Actions for one selected blend with any tool, #4817; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -376,6 +376,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Export for Screens is laid out as Illustrator's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged | | 2026-10-31 | minor | Properties: Quick Actions has Blend Options for one selected blend with any tool, not only the Blend tool, as the blending page describes (#4817); scores unchanged | | 2026-10-31 | minor | Smart guides and snapping: Snap to Perpendicular and Snap to Tangent for an existing line's end dragged with Direct Selection (#2530), so the tangent and perpendicular row is done; scores and estimates unchanged | | 2026-10-31 | minor | Grid Repeat Options has a Preview checkbox (#2651); scores and estimates unchanged | From ca0785573d22231a0530602bf33dbeeea08ae485 Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Sat, 31 Oct 2026 19:24:09 +0700 Subject: [PATCH 93/98] =?UTF-8?q?Add-ons:=20Arrange=20Tools=20=E2=80=94=20?= =?UTF-8?q?Arrange=20in=20Grid,=20Swap,=20Replace=20with=20Top=20Object,?= =?UTF-8?q?=20Set=20Size=20of=20Each,=20Zoom=20to=20Selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A built-in add-on in Object › Plug-ins › Arrange Tools (#4830, #4831, #4832, #4833, #4834): - addon.arrange.grid lays the top-level selected objects out in rows and columns, a row or a column (stacking order from the back, visual bounds, each object centred in its cell), from the selection's top-left or centred on the artboard. - addon.arrange.swap swaps two objects' bounds centres, and with `size` their widths and heights. - addon.arrange.replace puts a copy of the topmost selected object centred on each of the others, in its stacking place, kept, stretched or fitted in proportion; the replaced objects are deleted unless `keep`. - addon.arrange.size gives each selected object an exact width and/or height about a reference point. - addon.arrange.zoomSelection (a UI command) fits the active view to the selection. Places and sizes are measured as object.setBounds measures them (its transform, now also available per object), strokes scale as Scale Strokes & Effects says, each command is one undo step, and Arrange in Grid, Replace with Top Object and Set Size of Each open previewed dialogs. Catalog rows, command sweep cases, docs/mcp.md, parity docs and the ROADMAP progress log. --- ROADMAP.md | 4 +- crates/engine/src/cmd/addons.rs | 4 +- crates/engine/src/cmd/addons/arrange.rs | 272 ++++++++++++++++++ crates/engine/src/cmd/addons/arrange/tests.rs | 263 +++++++++++++++++ crates/engine/src/cmd/object.rs | 2 +- crates/engine/src/cmd/object/bounds.rs | 22 +- crates/engine/tests/command_sweep.rs | 9 + crates/ui-egui/src/addons.rs | 2 + crates/ui-egui/src/addons/arrange.rs | 175 +++++++++++ crates/ui-egui/src/addons/arrange/tests.rs | 127 ++++++++ crates/ui-egui/src/canvas/view.rs | 3 +- crates/ui-egui/src/dialogs.rs | 3 +- crates/ui-egui/src/dialogs/form.rs | 12 +- crates/ui-egui/src/i18n/bg.tsv | 12 + crates/ui-egui/src/i18n/bs.tsv | 12 + crates/ui-egui/src/i18n/ca.tsv | 12 + crates/ui-egui/src/i18n/cs.tsv | 12 + crates/ui-egui/src/i18n/da.tsv | 12 + crates/ui-egui/src/i18n/de.tsv | 12 + crates/ui-egui/src/i18n/el.tsv | 12 + crates/ui-egui/src/i18n/es.tsv | 12 + crates/ui-egui/src/i18n/fi.tsv | 12 + crates/ui-egui/src/i18n/fr.tsv | 12 + crates/ui-egui/src/i18n/hu.tsv | 12 + crates/ui-egui/src/i18n/id.tsv | 12 + crates/ui-egui/src/i18n/it.tsv | 12 + crates/ui-egui/src/i18n/ja.tsv | 12 + crates/ui-egui/src/i18n/ka.tsv | 12 + crates/ui-egui/src/i18n/ko.tsv | 12 + crates/ui-egui/src/i18n/nl.tsv | 12 + crates/ui-egui/src/i18n/pl.tsv | 12 + crates/ui-egui/src/i18n/pt-br.tsv | 12 + crates/ui-egui/src/i18n/ru.tsv | 12 + crates/ui-egui/src/i18n/sv.tsv | 12 + crates/ui-egui/src/i18n/tr.tsv | 12 + crates/ui-egui/src/i18n/uk.tsv | 12 + crates/ui-egui/src/i18n/vi.tsv | 12 + crates/ui-egui/src/i18n/zh-hans.tsv | 12 + crates/ui-egui/src/i18n/zh-hant.tsv | 12 + crates/ui-egui/src/menus/invoke.rs | 3 + crates/ui-egui/src/menus/run.rs | 1 + crates/ui-egui/src/menus/state.rs | 2 +- crates/ui-egui/src/menus/ui_commands.rs | 8 +- docs/mcp.md | 26 ++ docs/target-app-parity.md | 29 +- docs/ui-parity.md | 24 +- 46 files changed, 1269 insertions(+), 34 deletions(-) create mode 100644 crates/engine/src/cmd/addons/arrange.rs create mode 100644 crates/engine/src/cmd/addons/arrange/tests.rs create mode 100644 crates/ui-egui/src/addons/arrange.rs create mode 100644 crates/ui-egui/src/addons/arrange/tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index e7d8f0bbad..515e2824d6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,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-10-23 · **Change:** minor (OpenType SVG colour fonts draw and export as vector art; previously: COLR colour fonts (v0 and v1) draw and export as vector art; previously: 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; previously: colour emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; headline numbers unchanged; previously:; Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; ruby (ふりがな) for live text: `type.ruby` sets or 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); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Arrange Tools add-on: Arrange in Grid, Swap Objects, Replace with Top Object, Set Size of Each and Zoom to Selection; headline numbers unchanged; previously: OpenType SVG colour fonts draw and export as vector art; previously: COLR colour fonts (v0 and v1) draw and export as vector art; previously: 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; previously: colour emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; headline numbers unchanged; previously:; Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; ruby (ふりがな) for live text: `type.ruby` sets or 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); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers 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 @@ -129,6 +129,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log +- 2026-10-31: Arrange Tools, a built-in add-on (Object › Plug-ins › Arrange Tools): Arrange in Grid lays the selected objects out in rows and columns, a row or a column with gaps, from the selection's top-left or centred on the artboard; Swap Objects swaps two objects' places (and, with Swap Objects and Sizes, their sizes); Replace with Top Object puts a copy of the topmost selected object in place of each of the others, kept, stretched or fitted in proportion; Set Size of Each gives every selected object an exact width and/or height about a reference point; Zoom to Selection fits the view to the selection. Each is one undo step with a previewed dialog where it has options. A VectorCraft addition: headline numbers unchanged. - 2026-10-30: Gradient panel presets: a Presets menu applies one of the built-in gradient libraries' presets (grouped by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the current gradient as a preset, kept with the preferences, and deletes saved ones (`gradient.presets.list/apply/save/delete`). - 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged. - 2026-10-23: OpenType SVG colour fonts draw as vector art: each glyph's SVG document is read by the SVG importer, in the text's colours where it asks for them, and draws, exports and outlines as COLR glyphs do; all colour font formats are now drawn. @@ -202,6 +203,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Progress log row for the Arrange Tools add-on; headline numbers unchanged. | | 2026-10-29 | minor | Progress log row for the Linux Flatpak clipboard's core Wayland protocol text fallback; headline numbers unchanged. | | 2026-10-29 | minor | Progress log row for the Linux clipboard retry backoff; headline numbers unchanged. | | 2026-10-23 | minor | Progress log row for Pathfinder › Trap; Live vector effects hours 6–10 → 4–8; headline numbers unchanged. | diff --git a/crates/engine/src/cmd/addons.rs b/crates/engine/src/cmd/addons.rs index ca6625540e..2e153491b1 100644 --- a/crates/engine/src/cmd/addons.rs +++ b/crates/engine/src/cmd/addons.rs @@ -1,6 +1,7 @@ //! VectorCraft's built-in add-ons: extra tools beyond the standard menus, each a module of its own //! with commands under `addon.<name>.*` (see `docs/mcp.md`). +mod arrange; mod cleanup; mod color; mod pathtools; @@ -10,7 +11,8 @@ use super::CommandSpec; /// The commands of every built-in add-on. pub fn specs() -> Vec<CommandSpec> { - let mut v = cleanup::specs(); + let mut v = arrange::specs(); + v.extend(cleanup::specs()); v.extend(color::specs()); v.extend(pathtools::specs()); v.extend(pen::specs()); diff --git a/crates/engine/src/cmd/addons/arrange.rs b/crates/engine/src/cmd/addons/arrange.rs new file mode 100644 index 0000000000..2011559c3b --- /dev/null +++ b/crates/engine/src/cmd/addons/arrange.rs @@ -0,0 +1,272 @@ +//! Arrange Tools (`addon.arrange.*`), a built-in add-on: Arrange in Grid lays the selected objects +//! out in rows and columns, a row or a column; Swap Objects swaps the places (and sizes) of two; +//! Replace with Top Object puts a copy of the topmost selected object in place of each of the +//! others; and Set Size of Each gives every selected object an exact width and height. Places and +//! sizes are measured and set as `object.setBounds` does. Its submenu (Object › Plug-ins › Arrange +//! Tools), its dialogs and Zoom to Selection (a view command) are the UI's. + +#[cfg(test)] +mod tests; + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, OrientedBox}; +use vectorcraft_geom::{Affine, Point, Rect}; +use vectorcraft_render::effects; + +use super::super::edit::selected_roots; +use super::super::object::{choice, object_bounds_transform, scaling, transform_node, transform_patterns}; +use super::super::typecmd::refresh_bounds_of; +use super::super::{CommandSpec, bad, cmd, has_doc, has_multi, has_selection, number_or, switch_or}; +use crate::{EngineError, Result, Session}; + +/// The largest gap, width or height (pt) the commands take. +const LIMIT: f64 = 1e6; +/// The smallest width or height (pt) Set Size of Each takes. +const LEAST: f64 = 0.001; +/// The most columns Arrange in Grid takes. +const MAX_COLUMNS: u64 = 10_000; + +pub fn specs() -> Vec<CommandSpec> { + vec![ + cmd!( + "addon.arrange.grid", + "Arrange in Grid", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{layout?: \"grid\" (rows and columns) | \"row\" (one row) | \"column\" (one column), columns?: 1..10000 (grid; default ⌈√count⌉; never more than the count), gapX?: pt −1000000..1000000 (10), gapY?: pt −1000000..1000000 (gapX), centerOnArtboard?: false (true: the grid is centred on the artboard under the selection's centre, else the first; false: it starts at the selection's top-left)} lay the selected objects (the top-level ones) out in their stacking order from the back, left to right and top to bottom, as one undo step. Each cell is as wide as the widest object in its column and as tall as the tallest in its row, each object is centred in its cell, and everything is measured by visual bounds (strokes and effects as drawn) → {count}", + has_selection, + grid + ), + cmd!( + "addon.arrange.swap", + "Swap Objects", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{size?: false (true: each also takes the other's width and height), scaleStrokes?: the Scale Strokes & Effects preference} swap the places (bounds centres) of the two selected objects, measured as object.setBounds measures them (Use Preview Bounds), as one undo step. Off unless exactly two objects are selected", + exactly_two, + swap + ), + cmd!( + "addon.arrange.replace", + "Replace with Top Object", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{fit?: \"keep\" (the copy keeps its size) | \"bounds\" (scaled to the replaced object's width and height) | \"proportional\" (scaled to fit inside them, in proportion) (\"keep\"), scaleStrokes?: the Scale Strokes & Effects preference, keep?: false (true: the replaced objects stay, under their copies)} put a copy of the topmost selected object centred on each of the other selected objects, in that object's place in the stacking order, as one undo step; sizes and centres are measured as object.setBounds measures them, and the copies are selected → {ids}", + has_multi, + replace + ), + cmd!( + "addon.arrange.size", + "Set Size of Each", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{width?: pt, height?: pt (at least one; each 0.001..1000000), proportional?: false (with only one of them given, the other follows in proportion; else it stays), reference?: 0..8 (the 9-point grid row by row from the top-left; 4, the centre: the point of each object that stays put), scaleStrokes?: the Scale Strokes & Effects preference} give each selected object (the top-level ones) that width and height, measured as object.setBounds measures them (Use Preview Bounds), as one undo step → {count}", + has_selection, + size + ), + ] +} + +/// Exactly two objects are selected (Swap Objects). +fn exactly_two(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + let n = selected_roots(s).map_err(|e| e.to_string())?.len(); + if n == 2 { Ok(()) } else { Err("select exactly two objects".into()) } +} + +/// Number param `key` (`default` when left out) within `lo..=hi`. +fn number_in(p: &Value, key: &str, default: f64, (lo, hi): (f64, f64), cmd: &str) -> Result<f64> { + let v = number_or(p, key, default, cmd)?; + if (lo..=hi).contains(&v) { Ok(v) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) } +} + +/// Whole-number param `key` within `lo..=hi`, `None` when left out. +fn whole_in(p: &Value, key: &str, (lo, hi): (u64, u64), cmd: &str) -> Result<Option<u64>> { + let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; + let n = v.as_u64().ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number")))?; + if (lo..=hi).contains(&n) { Ok(Some(n)) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) } +} + +/// A width or height param of Set Size of Each, `None` when left out. +fn size_param(p: &Value, key: &str, cmd: &str) -> Result<Option<f64>> { + p.get(key).filter(|v| !v.is_null()).map(|_| number_in(p, key, 0.0, (LEAST, LIMIT), cmd)).transpose() +} + +/// `object.setBounds` params that centre an object on `at`, with Scale Strokes & Effects `strokes` +/// and, when given, a width and height. +fn place(at: Point, size: Option<(f64, f64)>, strokes: bool) -> Value { + let mut q = json!({ "x": at.x, "y": at.y, "reference": 4, "strokes": strokes }); + if let Some((w, h)) = size { + q["width"] = json!(w); + q["height"] = json!(h); + } + q +} + +/// Transform each object of `xfs` by its own transform as undo step `label`, strokes and effects +/// scaling with Scale Strokes & Effects `strokes`, pattern tiles as Transform Pattern Tiles says. +/// When every transform is (within rounding) the identity, nothing changes. +fn transform_each(s: &mut Session, label: &str, strokes: bool, xfs: &[(NodeId, Affine)]) -> Result<()> { + let still = |xf: &Affine| xf.as_coeffs().iter().zip(Affine::IDENTITY.as_coeffs()).all(|(a, b)| (a - b).abs() <= 1e-9); + if xfs.iter().all(|(_, xf)| still(xf)) { + return Ok(()); + } + let p = json!({ "strokes": strokes }); + let ids: Vec<NodeId> = xfs.iter().map(|(id, _)| *id).collect(); + let mut sc = scaling(s, &p); + sc.patterns = transform_patterns(s, &p, &ids)?; + s.edit(label, |d, _| { + for (id, xf) in xfs { + if let Some(n) = d.node_mut(*id) { + transform_node(n, *xf, sc, true); + } + } + Ok(()) + }) +} + +/// The box of object `id` as `object.setBounds` measures it. +fn box_of(s: &Session, id: NodeId) -> Result<OrientedBox> { + s.transform_box(&[id]).ok_or_else(|| EngineError::Other("no bounds".into())) +} + +fn grid(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "addon.arrange.grid"; + let layout = choice(p, C, "layout", &["grid", "row", "column"])?.unwrap_or("grid"); + let columns = whole_in(p, "columns", (1, MAX_COLUMNS), C)?; + let gap_x = number_in(p, "gapX", 10.0, (-LIMIT, LIMIT), C)?; + let gap_y = number_in(p, "gapY", gap_x, (-LIMIT, LIMIT), C)?; + let center = switch_or(p, "centerOnArtboard", false, C)?; + let roots = selected_roots(s)?; + let d = &s.doc()?.doc; + let items: Vec<(NodeId, Rect)> = roots.iter().filter_map(|id| Some((*id, effects::preview::bounds_of(d, &[*id], true)?))).collect(); + let Some(all) = items.iter().map(|(_, b)| *b).reduce(|a, b| a.union(b)) else { return Ok(json!({ "count": 0 })) }; + let n = items.len(); + let cols = match layout { + "row" => n, + "column" => 1, + _ => columns.map_or_else(|| (n as f64).sqrt().ceil() as usize, |c| usize::try_from(c).unwrap_or(n)), + } + .clamp(1, n); + let mut widths = vec![0.0_f64; cols]; + let mut heights = vec![0.0_f64; n.div_ceil(cols)]; + for (i, (_, b)) in items.iter().enumerate() { + if let Some(w) = widths.get_mut(i % cols) { + *w = w.max(b.width()); + } + if let Some(h) = heights.get_mut(i / cols) { + *h = h.max(b.height()); + } + } + let total = |sizes: &[f64], gap: f64| sizes.iter().sum::<f64>() + gap * sizes.len().saturating_sub(1) as f64; + let origin = if center { + let board = d.artboards.get(d.artboard_at(all.center()).unwrap_or(0)).map_or(all, |a| a.rect); + Point::new(board.center().x - total(&widths, gap_x) / 2.0, board.center().y - total(&heights, gap_y) / 2.0) + } else { + Point::new(all.x0, all.y0) + }; + // The middle of each column (row): the sizes and gaps before it, and half its own size. + let middles = |sizes: &[f64], gap: f64, from: f64| -> Vec<f64> { + sizes + .iter() + .scan(from, |at, size| { + let mid = *at + size / 2.0; + *at += size + gap; + Some(mid) + }) + .collect() + }; + let (xs, ys) = (middles(&widths, gap_x, origin.x), middles(&heights, gap_y, origin.y)); + let xfs: Vec<(NodeId, Affine)> = items + .iter() + .enumerate() + .filter_map(|(i, (id, b))| Some((*id, Affine::translate(Point::new(*xs.get(i % cols)?, *ys.get(i / cols)?) - b.center())))) + .collect(); + transform_each(s, "Arrange in Grid", false, &xfs)?; + Ok(json!({ "count": n })) +} + +fn swap(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "addon.arrange.swap"; + let size = switch_or(p, "size", false, C)?; + let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?; + let roots = selected_roots(s)?; + let &[a, b] = roots.as_slice() else { return Err(bad(C, "select exactly two objects")) }; + let (ba, bb) = (box_of(s, a)?, box_of(s, b)?); + let to = |o: &OrientedBox| place(o.center(), size.then(|| (o.rect.width(), o.rect.height())), strokes); + let xfs = [(a, object_bounds_transform(s, &to(&bb), a)?), (b, object_bounds_transform(s, &to(&ba), b)?)]; + transform_each(s, "Swap Objects", strokes, &xfs)?; + Ok(json!({})) +} + +fn replace(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "addon.arrange.replace"; + let fit = choice(p, C, "fit", &["keep", "bounds", "proportional"])?.unwrap_or("keep"); + let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?; + let keep = switch_or(p, "keep", false, C)?; + let roots = selected_roots(s)?; + let Some((&top, others)) = roots.split_last().filter(|(_, others)| !others.is_empty()) else { + return Err(bad(C, "select at least two objects")); + }; + let tb = box_of(s, top)?; + let (tw, th) = (tb.rect.width(), tb.rect.height()); + let mut xfs = Vec::with_capacity(others.len()); + for &id in others { + let Some(b) = s.transform_box(&[id]) else { continue }; + let (w, h) = (b.rect.width(), b.rect.height()); + let size = match fit { + "bounds" => Some((w, h)), + // The smaller of the two scales that fit; a side the copy doesn't have doesn't count. + "proportional" => { + [(w, tw), (h, th)].into_iter().filter(|(_, t)| *t > 1e-9).map(|(v, t)| v / t).reduce(f64::min).map(|k| (tw * k, th * k)) + } + _ => None, + }; + xfs.push((id, object_bounds_transform(s, &place(b.center(), size, strokes), top)?)); + } + let p = json!({ "strokes": strokes }); + let mut sc = scaling(s, &p); + sc.patterns = transform_patterns(s, &p, &[top])?; + let ids = s.edit("Replace with Top Object", |d, sel| { + let Some(n) = d.node(top).cloned() else { return Ok(vec![]) }; + let mut items = Vec::with_capacity(xfs.len()); + for (id, xf) in &xfs { + let mut c = d.reid(&n); + transform_node(&mut c, *xf, sc, true); + items.push((*id, vec![c])); + } + let out: Vec<NodeId> = items.iter().flat_map(|(_, c)| c.iter().map(|n| n.id)).collect(); + d.insert_beside(items, !keep)?; + refresh_bounds_of(d, &out); + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })) +} + +fn size(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "addon.arrange.size"; + let width = size_param(p, "width", C)?; + let height = size_param(p, "height", C)?; + if width.is_none() && height.is_none() { + return Err(bad(C, "give `width` or `height`")); + } + let proportional = switch_or(p, "proportional", false, C)?; + let reference = whole_in(p, "reference", (0, 8), C)?.unwrap_or(4); + let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?; + let mut q = json!({ "reference": reference, "strokes": strokes, "proportional": proportional && (width.is_none() || height.is_none()) }); + if let Some(w) = width { + q["width"] = json!(w); + } + if let Some(h) = height { + q["height"] = json!(h); + } + let mut xfs = vec![]; + for id in selected_roots(s)? { + if s.transform_box(&[id]).is_some() { + xfs.push((id, object_bounds_transform(s, &q, id)?)); + } + } + transform_each(s, "Set Size of Each", strokes, &xfs)?; + Ok(json!({ "count": xfs.len() })) +} diff --git a/crates/engine/src/cmd/addons/arrange/tests.rs b/crates/engine/src/cmd/addons/arrange/tests.rs new file mode 100644 index 0000000000..db19cc1e9e --- /dev/null +++ b/crates/engine/src/cmd/addons/arrange/tests.rs @@ -0,0 +1,263 @@ +//! Arrange Tools' commands: the geometry each one leaves, one undo step each, when they are off, +//! and bad params refused without a change. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; +use vectorcraft_geom::Rect; + +use crate::Session; + +/// A new document whose new art has no stroke, so visual and geometric bounds agree. +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap()) +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap(); +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() +} + +fn near(a: Rect, b: (f64, f64, f64, f64)) -> bool { + [(a.x0, b.0), (a.y0, b.1), (a.width(), b.2), (a.height(), b.3)].iter().all(|(x, y)| (x - y).abs() < 1e-6) +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// Each of `bad` is refused and leaves the document as it was. +fn refuses(s: &mut Session, cmd: &str, bad: &[Value]) { + let (before, n) = (s.doc().unwrap().doc.clone(), steps(s)); + for p in bad { + assert!(s.execute(cmd, p).is_err(), "{cmd} {p}"); + } + assert_eq!(steps(s), n, "{cmd}"); + assert!(*s.doc().unwrap().doc == *before, "{cmd} changed the document"); +} + +fn enabled(s: &Session, cmd: &str) -> bool { + s.commands().into_iter().find(|c| c.id == cmd).unwrap().enabled +} + +#[test] +fn grid_lays_objects_out_in_rows_and_columns_from_the_back() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 20.0, 10.0); + let b = rect(&mut s, 300.0, 50.0, 40.0, 30.0); + let c = rect(&mut s, 10.0, 200.0, 10.0, 10.0); + let d = rect(&mut s, 50.0, 5.0, 30.0, 20.0); + select(&mut s, &[d, c, b, a]); + let n = steps(&s); + // Four objects: two columns. The grid starts at the selection's top-left (10, 5). + let r = s.execute("addon.arrange.grid", &json!({"gapX": 5, "gapY": 2})).unwrap(); + assert_eq!(r["count"], json!(4)); + assert_eq!(steps(&s), n + 1, "one undo step"); + // Columns 20 and 40 wide (the widest in each), rows 30 and 20 tall; each object centred. + assert!(near(bounds(&s, a), (10.0, 15.0, 20.0, 10.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (35.0, 5.0, 40.0, 30.0)), "{:?}", bounds(&s, b)); + assert!(near(bounds(&s, c), (15.0, 42.0, 10.0, 10.0)), "{:?}", bounds(&s, c)); + assert!(near(bounds(&s, d), (40.0, 37.0, 30.0, 20.0)), "{:?}", bounds(&s, d)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(bounds(&s, a), (100.0, 100.0, 20.0, 10.0))); +} + +#[test] +fn grid_lays_out_a_row_or_a_column_and_centres_on_the_artboard() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let b = rect(&mut s, 100.0, 100.0, 20.0, 20.0); + select(&mut s, &[a, b]); + s.execute("addon.arrange.grid", &json!({"layout": "row"})).unwrap(); + assert!(near(bounds(&s, b), (30.0, 0.0, 20.0, 20.0)), "gapX defaults to 10"); + s.execute("addon.arrange.grid", &json!({"layout": "column", "gapX": 4})).unwrap(); + assert!(near(bounds(&s, b), (0.0, 24.0, 20.0, 20.0)), "gapY follows gapX"); + // 20 × 44 centred on the 400 × 300 artboard. + s.execute("addon.arrange.grid", &json!({"layout": "column", "gapY": 4, "centerOnArtboard": true})).unwrap(); + assert!(near(bounds(&s, a), (190.0, 128.0, 20.0, 20.0)), "{:?}", bounds(&s, a)); + // In place already: no step. + let n = steps(&s); + s.execute("addon.arrange.grid", &json!({"layout": "column", "gapY": 4, "centerOnArtboard": true})).unwrap(); + assert_eq!(steps(&s), n); + // More columns than objects: one row. + s.execute("addon.arrange.grid", &json!({"columns": 50, "gapX": 0})).unwrap(); + assert!(near(bounds(&s, b), (210.0, 128.0, 20.0, 20.0)), "{:?}", bounds(&s, b)); +} + +#[test] +fn grid_measures_visual_bounds() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let b = rect(&mut s, 100.0, 0.0, 20.0, 20.0); + select(&mut s, &[a, b]); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 4})).unwrap(); + s.execute("addon.arrange.grid", &json!({"layout": "row", "gapX": 0})).unwrap(); + // Visual boxes 24 wide, touching. + assert!(near(bounds(&s, b), (24.0, 0.0, 20.0, 20.0)), "{:?}", bounds(&s, b)); +} + +#[test] +fn swap_trades_places_and_optionally_sizes() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0); + let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0); + let c = rect(&mut s, 200.0, 200.0, 5.0, 5.0); + select(&mut s, &[a]); + assert!(!enabled(&s, "addon.arrange.swap")); + select(&mut s, &[a, b, c]); + assert!(!enabled(&s, "addon.arrange.swap")); + refuses(&mut s, "addon.arrange.swap", &[json!({})]); + select(&mut s, &[a, b]); + assert!(enabled(&s, "addon.arrange.swap")); + let n = steps(&s); + s.execute("addon.arrange.swap", &json!({})).unwrap(); + assert_eq!(steps(&s), n + 1); + // Centres (10, 5) and (120, 120) swap; sizes stay. + assert!(near(bounds(&s, a), (110.0, 115.0, 20.0, 10.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (-10.0, -15.0, 40.0, 40.0)), "{:?}", bounds(&s, b)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("addon.arrange.swap", &json!({"size": true})).unwrap(); + assert!(near(bounds(&s, a), (100.0, 100.0, 40.0, 40.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (0.0, 0.0, 20.0, 10.0)), "{:?}", bounds(&s, b)); +} + +#[test] +fn swap_with_sizes_scales_strokes_as_asked() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 100.0, 0.0, 20.0, 20.0); + select(&mut s, &[a, b]); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 2})).unwrap(); + let weight = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().appearance.stroke_width(); + s.execute("addon.arrange.swap", &json!({"size": true})).unwrap(); + assert_eq!((weight(&s, a), weight(&s, b)), (2.0, 2.0), "off by default (the preference)"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("addon.arrange.swap", &json!({"size": true, "scaleStrokes": true})).unwrap(); + assert!((weight(&s, a) - 4.0).abs() < 1e-9 && (weight(&s, b) - 1.0).abs() < 1e-9, "{} {}", weight(&s, a), weight(&s, b)); +} + +#[test] +fn replace_puts_a_copy_of_the_top_object_in_each_place() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 40.0); + let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0); + let top = rect(&mut s, 200.0, 200.0, 10.0, 5.0); + select(&mut s, &[top]); + assert!(!enabled(&s, "addon.arrange.replace")); + refuses(&mut s, "addon.arrange.replace", &[json!({})]); + select(&mut s, &[a, b, top]); + let n = steps(&s); + let r = s.execute("addon.arrange.replace", &json!({})).unwrap(); + assert_eq!(steps(&s), n + 1); + let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(ids.len(), 2); + let st = s.doc().unwrap(); + assert!(st.doc.node(a).is_none() && st.doc.node(b).is_none(), "replaced objects deleted"); + assert!(st.doc.node(top).is_some(), "the top object stays"); + assert_eq!(st.selection.objects, ids, "the copies are selected"); + // Kept size, centred on (10, 20) and (120, 120); each in its object's place (bottom, then middle). + assert!(near(bounds(&s, ids[0]), (5.0, 17.5, 10.0, 5.0)), "{:?}", bounds(&s, ids[0])); + assert!(near(bounds(&s, ids[1]), (115.0, 117.5, 10.0, 5.0)), "{:?}", bounds(&s, ids[1])); + let layer = &s.doc().unwrap().doc.layers[0]; + let order: Vec<NodeId> = layer.children().unwrap().iter().map(|n| n.id).collect(); + assert_eq!(order, [ids[0], ids[1], top]); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(a).is_some()); +} + +#[test] +fn replace_fits_the_copies_and_can_keep_the_originals() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 40.0); + let top = rect(&mut s, 200.0, 200.0, 10.0, 5.0); + select(&mut s, &[a, top]); + let r = s.execute("addon.arrange.replace", &json!({"fit": "bounds", "keep": true})).unwrap(); + let copy = NodeId(r["ids"][0].as_u64().unwrap()); + assert!(near(bounds(&s, copy), (0.0, 0.0, 20.0, 40.0)), "{:?}", bounds(&s, copy)); + let order: Vec<NodeId> = s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + assert_eq!(order, [a, copy, top], "kept: the copy goes right above it"); + s.execute("edit.undo", &json!({})).unwrap(); + select(&mut s, &[a, top]); + let r = s.execute("addon.arrange.replace", &json!({"fit": "proportional"})).unwrap(); + let copy = NodeId(r["ids"][0].as_u64().unwrap()); + // 10 × 5 into 20 × 40: twice as large, centred on (10, 20). + assert!(near(bounds(&s, copy), (0.0, 15.0, 20.0, 10.0)), "{:?}", bounds(&s, copy)); +} + +#[test] +fn size_gives_each_object_a_width_and_height() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0); + let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0); + select(&mut s, &[a, b]); + let n = steps(&s); + s.execute("addon.arrange.size", &json!({"width": 30})).unwrap(); + assert_eq!(steps(&s), n + 1); + // About each centre; the height stays. + assert!(near(bounds(&s, a), (-5.0, 0.0, 30.0, 10.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (105.0, 100.0, 30.0, 40.0)), "{:?}", bounds(&s, b)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("addon.arrange.size", &json!({"width": 40, "proportional": true, "reference": 0})).unwrap(); + assert!(near(bounds(&s, a), (0.0, 0.0, 40.0, 20.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (100.0, 100.0, 40.0, 40.0)), "{:?}", bounds(&s, b)); + s.execute("addon.arrange.size", &json!({"width": 10, "height": 12, "proportional": true, "reference": 8})).unwrap(); + assert!(near(bounds(&s, a), (30.0, 8.0, 10.0, 12.0)), "both given: both set, {:?}", bounds(&s, a)); +} + +#[test] +fn bad_params_are_refused() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0); + let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0); + select(&mut s, &[a, b]); + refuses( + &mut s, + "addon.arrange.grid", + &[ + json!({"layout": "spiral"}), + json!({"columns": 0}), + json!({"columns": 1.5}), + json!({"columns": 1e12}), + json!({"gapX": 1e308}), + json!({"gapY": "10"}), + json!({"centerOnArtboard": "yes"}), + ], + ); + refuses(&mut s, "addon.arrange.swap", &[json!({"size": 1}), json!({"scaleStrokes": "no"})]); + refuses(&mut s, "addon.arrange.replace", &[json!({"fit": "stretch"}), json!({"keep": 0})]); + refuses( + &mut s, + "addon.arrange.size", + &[ + json!({}), + json!({"width": 0}), + json!({"height": -5}), + json!({"width": 1e308}), + json!({"width": "10"}), + json!({"width": 10, "reference": 9}), + json!({"width": 10, "proportional": "yes"}), + ], + ); +} + +#[test] +fn off_without_a_selection() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 20.0, 10.0); + s.execute("select.none", &json!({})).unwrap(); + for c in ["addon.arrange.grid", "addon.arrange.swap", "addon.arrange.replace", "addon.arrange.size"] { + assert!(!enabled(&s, c), "{c}"); + } + assert!(!enabled(&Session::new(), "addon.arrange.swap")); +} diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 4839a0ac41..bac3540fad 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -32,7 +32,7 @@ mod transform; pub(crate) use align::choice; pub(crate) use arrange::group_nodes; -pub(crate) use bounds::bounds_rewrite; +pub(crate) use bounds::{bounds_rewrite, object_bounds_transform}; pub use commands::specs; pub(super) use compound::{as_clipping_path, make_clipping_path, release_clip}; pub(super) use shape::recut; diff --git a/crates/engine/src/cmd/object/bounds.rs b/crates/engine/src/cmd/object/bounds.rs index 2e07471706..9d4795235f 100644 --- a/crates/engine/src/cmd/object/bounds.rs +++ b/crates/engine/src/cmd/object/bounds.rs @@ -6,7 +6,7 @@ use super::{ }; pub(super) fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> { - match bounds_transform(s, p, selected_roots_to_transform(s)?, 0.0)? { + match bounds_transform(s, p, selected_roots_to_transform(s)?, 0.0, true)? { Bounds::Anchors(ids, b) => set_anchor_bounds(s, p, ids, b), Bounds::Objects(ids, xf) => apply_transform(s, "Transform", ids, xf, p, false), } @@ -32,7 +32,7 @@ pub(crate) fn bounds_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<( if cmd != "object.setBounds" { return None; } - let Ok(Bounds::Objects(_, xf)) = bounds_transform(s, params, selected_roots_to_transform(s).ok()?, 1e-4) else { return None }; + let Ok(Bounds::Objects(_, xf)) = bounds_transform(s, params, selected_roots_to_transform(s).ok()?, 1e-4, true) else { return None }; let mut p = params.as_object().cloned().unwrap_or_default(); for k in ["x", "y", "width", "height", "reference", "proportional"] { p.remove(k); @@ -41,14 +41,26 @@ pub(crate) fn bounds_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<( Some(("object.transform".to_string(), Value::Object(p))) } +/// The transform that gives object `id` on its own the place and size `p` asks for, as +/// `object.setBounds` measures them (Use Preview Bounds, `strokes`, `reference`, `proportional`), +/// whatever anchors are direct-selected. The art keeps at least 1e-4 of its width and height. +pub(crate) fn object_bounds_transform(s: &Session, p: &Value, id: NodeId) -> Result<Affine> { + match bounds_transform(s, p, vec![id], 1e-4, false)? { + Bounds::Objects(_, xf) => Ok(xf), + Bounds::Anchors(..) => Err(EngineError::Other("no bounds".into())), + } +} + /// How `object.setBounds` with params `p` changes the objects `ids`. A scale that would leave the -/// art with a width or height below `least` of the one it has keeps `least` of it. -fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64) -> Result<Bounds> { +/// art with a width or height below `least` of the one it has keeps `least` of it. With `anchors`, +/// direct-selected anchors are what changes ([`Bounds::Anchors`]). +fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64, anchors: bool) -> Result<Bounds> { check_numbers(p, &["x", "y", "width", "height", "reference"], "object.setBounds")?; let st = s.doc()?; check_switches(p, &TRANSFORM_SWITCHES, "object.setBounds")?; check_switches(p, &["proportional"], "object.setBounds")?; - if transforms_objects(p) + if anchors + && transforms_objects(p) && !bool_or(p, "copy", false) && let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds) { diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index e80e93ce8a..69c69097ec 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -579,6 +579,15 @@ fn structured_junk() { ("addon.pathtools.movePointsTogether", json!({"enabled": true})), ("addon.pathtools.slideAnchor", json!({"id": 2, "anchor": 1, "t": 0.5})), ("addon.pathtools.movePointsTogether", json!({"enabled": false})), + // Arrange Tools add-on. + ("addon.arrange.grid", json!({"columns": u64::MAX, "gapX": 1e6, "gapY": -1e6, "centerOnArtboard": true})), + ("addon.arrange.grid", json!({"layout": "row", "gapX": f64::MAX, "columns": -1})), + ("addon.arrange.grid", json!({"layout": "column", "gapX": -1e6})), + ("addon.arrange.swap", json!({"size": true, "scaleStrokes": true})), + ("addon.arrange.replace", json!({"fit": "proportional", "keep": true})), + ("addon.arrange.replace", json!({"fit": "bounds", "scaleStrokes": true})), + ("addon.arrange.size", json!({"width": 1e6, "height": 0.001, "reference": 8, "scaleStrokes": true})), + ("addon.arrange.size", json!({"width": 0.001, "proportional": true, "reference": u64::MAX})), // Smart Pen add-on. ("addon.pen.append", json!({"id": 2, "x": 1e308, "y": 0, "in": [1e308, -1e308]})), ("addon.pen.append", json!({"id": 2, "subpath": u64::MAX, "x": 0, "y": 0, "segment": "curved"})), diff --git a/crates/ui-egui/src/addons.rs b/crates/ui-egui/src/addons.rs index 36106b72b6..4d148ff586 100644 --- a/crates/ui-egui/src/addons.rs +++ b/crates/ui-egui/src/addons.rs @@ -1,6 +1,7 @@ //! VectorCraft's built-in add-ons in the interface: each has a panel and a submenu at the top of //! Object › Plug-ins, and acts through its engine commands (`addon.<name>.*`). +pub mod arrange; pub mod cleanup; pub mod color; pub mod pathtools; @@ -11,6 +12,7 @@ use crate::menus::Item; /// The add-ons' submenus, then a separator: the head of Object › Plug-ins. pub fn plugin_menu() -> Vec<Item> { vec![ + Item::Sub("Arrange Tools", arrange::menu()), Item::Sub("Artwork Cleanup", cleanup::menu()), Item::Sub("Color Effects", color::menu()), Item::Sub("Path Precision Tools", pathtools::menu()), diff --git a/crates/ui-egui/src/addons/arrange.rs b/crates/ui-egui/src/addons/arrange.rs new file mode 100644 index 0000000000..8435483cb8 --- /dev/null +++ b/crates/ui-egui/src/addons/arrange.rs @@ -0,0 +1,175 @@ +//! Arrange Tools in the interface: its submenu (Object › Plug-ins › Arrange Tools); the dialogs of +//! Arrange in Grid, Replace with Top Object and Set Size of Each, which preview on the canvas and +//! run their `addon.arrange.*` command on OK; and Zoom to Selection ([`ZOOM`]), which fits the +//! active view to the selection. + +use egui::Ui; +use serde_json::{Value, json}; + +use crate::VectorcraftApp; +use crate::dialogs::{DialogSpec, form}; +use crate::menus::Item; +use crate::state::Dialog; +use crate::widgets; + +/// Zoom to Selection: a view command, since the view is the interface's. +pub const ZOOM: &str = "addon.arrange.zoomSelection"; +/// The dialogs' kinds. +pub const GRID: &str = "arrangeGrid"; +pub const REPLACE: &str = "arrangeReplace"; +pub const SIZE: &str = "arrangeSize"; +/// Each dialog's kind, command and heading. +const DIALOGS: [(&str, &str, &str); 3] = [ + (GRID, "addon.arrange.grid", "Arrange in Grid"), + (REPLACE, "addon.arrange.replace", "Replace with Top Object"), + (SIZE, "addon.arrange.size", "Set Size of Each"), +]; +/// The widths and heights Set Size of Each takes (pt), and the gaps Arrange in Grid takes. +const SIZES: std::ops::RangeInclusive<f64> = 0.001..=1e6; +const GAPS: std::ops::RangeInclusive<f64> = -1e6..=1e6; +const LABEL_W: f32 = 110.0; +const FIELD_W: f32 = 90.0; + +/// Object › Plug-ins › Arrange Tools. +pub fn menu() -> Vec<Item> { + vec![ + Item::Cmd("Arrange in Grid…", "addon.arrange.grid", Value::Null), + Item::Cmd("Swap Objects", "addon.arrange.swap", json!({})), + Item::Cmd("Swap Objects and Sizes", "addon.arrange.swap", json!({ "size": true })), + Item::Cmd("Replace with Top Object…", "addon.arrange.replace", Value::Null), + Item::Cmd("Set Size of Each…", "addon.arrange.size", Value::Null), + Item::Sep, + Item::Cmd("Zoom to Selection", ZOOM, json!({})), + ] +} + +/// The dialog (kind, fields) the menu item of command `id` opens; `None` for other commands and +/// without a document. +pub(crate) fn dialog(app: &VectorcraftApp, id: &str) -> Option<(&'static str, Value)> { + let &(kind, ..) = DIALOGS.iter().find(|(_, cmd, _)| *cmd == id)?; + let st = app.session.active()?; + let strokes = app.session.prefs.scale_strokes; + let fields = match kind { + // A square grid to start with: as many columns as the square root of the count. + GRID => json!({ + "layout": "grid", "columns": (st.selection.objects.len().max(1) as f64).sqrt().ceil() as u64, + "gapX": 10, "gapY": 10, "centerOnArtboard": false, + }), + REPLACE => json!({ "fit": "keep", "keep": false, "scaleStrokes": strokes }), + // The first selected object's size, as the command measures it (a side it lacks: the least). + _ => { + let b = st.selection.objects.first().and_then(|id| app.session.transform_box(&[*id])).map_or((100.0, 100.0), |b| { + (b.rect.width().clamp(*SIZES.start(), *SIZES.end()), b.rect.height().clamp(*SIZES.start(), *SIZES.end())) + }); + json!({ + "width": b.0, "height": b.1, "__width": true, "__height": true, "proportional": false, "reference": 4, "scaleStrokes": strokes, + }) + } + }; + Some((kind, fields)) +} + +pub(crate) const SPEC: DialogSpec = DialogSpec { + heading: |d| tl!(command(d).1).to_string(), + body, + confirm, + preview: true, + min_width: 320.0, + ok_enabled: Some(|_, d| d.kind != SIZE || d.bool("__width") || d.bool("__height")), + ..DialogSpec::FORM +}; + +/// The command and heading of dialog `d`. +fn command(d: &Dialog) -> (&'static str, &'static str) { + DIALOGS.iter().find(|(kind, ..)| *kind == d.kind).map_or(("", ""), |&(_, cmd, label)| (cmd, label)) +} + +/// The command's params: the fields, and of Set Size of Each's width and height those ticked. +fn params(d: &Dialog) -> Value { + let mut p = form::params(d); + if let Some(o) = p.as_object_mut() { + for side in ["width", "height"] { + if !d.bool(&format!("__{side}")) { + o.remove(side); + } + } + } + p +} + +fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let unit = app.session.general_unit(); + let widths = (LABEL_W, 150.0); + match d.kind.as_str() { + GRID => { + let layouts = [("grid", "Grid by Row"), ("row", "Arrange by Row"), ("column", "Arrange by Column")]; + form::choice(ui, d, "layout", tl!("Layout:"), widths, &layouts); + ui.add_enabled_ui(d.str("layout") == "grid", |ui| { + widgets::label_row(ui, tl!("Columns:"), LABEL_W, |ui| { + if let Some(v) = widgets::range_field(ui, ("arrange", "columns"), d.f64("columns", 1.0), 1.0..=10_000.0, "", 0, FIELD_W) { + d.fields.insert("columns".into(), json!(v as u64)); + } + }); + }); + widgets::label_row(ui, tl!("H Spacing:"), LABEL_W, |ui| { + form::length_within(ui, d, "gapX", unit, FIELD_W, Some(GAPS)); + }); + widgets::label_row(ui, tl!("V Spacing:"), LABEL_W, |ui| { + form::length_within(ui, d, "gapY", unit, FIELD_W, Some(GAPS)); + }); + form::check(ui, d, "centerOnArtboard", tl!("Center on Artboard")); + } + REPLACE => { + let fits = [("keep", "Actual Size"), ("bounds", "Bounds"), ("proportional", "Bounds (Fit Proportionally)")]; + form::choice(ui, d, "fit", tl!("Size:"), widths, &fits); + form::check(ui, d, "keep", tl!("Keep Original")); + form::check(ui, d, "scaleStrokes", tl!("Scale Strokes & Effects")); + } + _ => { + egui::Grid::new("arrange-size").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (side, tick, label) in [("width", "__width", tl!("Width:")), ("height", "__height", tl!("Height:"))] { + form::check(ui, d, tick, label); + let on = d.bool(tick); + ui.add_enabled_ui(on, |ui| { + form::length_within(ui, d, side, unit, FIELD_W, Some(SIZES)); + }); + ui.end_row(); + } + }); + // Only one size given: the other can follow it. + let one = d.bool("__width") != d.bool("__height"); + if widgets::check(ui, tl!("Constrain Width and Height Proportions"), d.bool("proportional"), one) { + let on = !d.bool("proportional"); + d.fields.insert("proportional".into(), json!(on)); + } + ui.horizontal(|ui| { + if let Some(i) = widgets::reference_point(ui, d.f64("reference", 4.0).clamp(0.0, 8.0) as usize) { + d.fields.insert("reference".into(), json!(i)); + } + widgets::dim_label(ui, tl!("Reference Point")); + }); + form::check(ui, d, "scaleStrokes", tl!("Scale Strokes & Effects")); + } + } + let (cmd, label) = command(d); + form::preview(app, ui, d, label, cmd, params(d)); + false +} + +/// OK: run the command (keep its preview) and close. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> { + form::commit_preview(app, command(d).0, params(d)) +} + +/// Zoom to Selection: the active view zooms and centres so the selection's bounds fill the +/// window, with a margin. An error with nothing selected. +pub(crate) fn zoom(app: &mut VectorcraftApp) -> Result<Value, String> { + if app.session.active().is_none_or(|st| st.selection.objects.is_empty()) { + return Err("nothing selected".into()); + } + crate::canvas::fit(app, ZOOM); + Ok(Value::Null) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/addons/arrange/tests.rs b/crates/ui-egui/src/addons/arrange/tests.rs new file mode 100644 index 0000000000..63e58448b6 --- /dev/null +++ b/crates/ui-egui/src/addons/arrange/tests.rs @@ -0,0 +1,127 @@ +use serde_json::json; +use vectorcraft_doc::NodeId; +use vectorcraft_engine::Session; + +use super::{GRID, REPLACE, SIZE, ZOOM}; +use crate::tests_labels::painted_text; +use crate::{VectorcraftApp, menus}; + +/// An app with two rectangles without a stroke, both selected. +fn app() -> (VectorcraftApp, NodeId, NodeId) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("paint.setStroke", json!({"none": true})).unwrap(); + let mut rect = |x, y, w, h| NodeId(app.run("shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap()); + let (a, b) = (rect(0, 0, 20, 10), rect(100, 100, 40, 40)); + app.run("select.all", json!({})).unwrap(); + (app, a, b) +} + +fn bounds(app: &VectorcraftApp, id: NodeId) -> vectorcraft_geom::Rect { + app.session.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() +} + +/// Click the Arrange Tools item `label`. +fn click(app: &mut VectorcraftApp, label: &str) { + let entries = menus::menu_entries(app); + let item = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Arrange Tools"] && e.label == label).expect(label); + let (id, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params); + menus::invoke(app, &id, p); +} + +#[test] +fn the_plugins_submenu_lists_the_tools() { + let (app, ..) = app(); + let entries = menus::menu_entries(&app); + let under = |label: &str| entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Arrange Tools"] && e.label == label); + for (label, id) in [ + ("Arrange in Grid…", "addon.arrange.grid"), + ("Swap Objects", "addon.arrange.swap"), + ("Swap Objects and Sizes", "addon.arrange.swap"), + ("Replace with Top Object…", "addon.arrange.replace"), + ("Set Size of Each…", "addon.arrange.size"), + ("Zoom to Selection", ZOOM), + ] { + assert_eq!(under(label).and_then(|e| e.command.as_deref()), Some(id), "{label}"); + } + assert!(entries.iter().any(|e| e.path == ["Object", "Plug-ins"] && e.label == "Install Plug-in…"), "the rest follows"); +} + +#[test] +fn arrange_in_grid_opens_its_dialog_and_runs_on_ok() { + let (mut app, a, b) = app(); + click(&mut app, "Arrange in Grid…"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(GRID)); + let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + for s in ["Arrange in Grid", "Layout:", "Columns:", "H Spacing:", "V Spacing:", "Center on Artboard", "Preview"] { + assert!(shown.contains(s), "{s} in {shown}"); + } + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!(d.fields["columns"], json!(2), "√2, rounded up"); + d.fields.insert("layout".into(), json!("row")); + let n = app.session.doc().unwrap().history.undo.len(); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().history.undo.len(), n + 1); + assert_eq!((bounds(&app, a).x0, bounds(&app, b).x0, bounds(&app, b).y0), (0.0, 30.0, 0.0)); +} + +#[test] +fn arrange_in_grid_previews_and_cancel_rolls_back() { + let (mut app, _, b) = app(); + click(&mut app, "Arrange in Grid…"); + app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(true)); + painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + assert_eq!(bounds(&app, b).x0, 30.0, "previewed"); + crate::dialogs::cancel(&mut app); + assert_eq!(bounds(&app, b).x0, 100.0, "rolled back"); +} + +#[test] +fn set_size_of_each_sets_the_sides_ticked() { + let (mut app, a, b) = app(); + click(&mut app, "Set Size of Each…"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(SIZE)); + let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + for s in ["Set Size of Each", "Width:", "Height:", "Constrain Width and Height Proportions", "Reference Point", "Scale Strokes & Effects"] { + assert!(shown.contains(s), "{s} in {shown}"); + } + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((&d.fields["width"], &d.fields["height"]), (&json!(20.0), &json!(10.0)), "the first object's size"); + d.fields.insert("width".into(), json!(30)); + d.fields.insert("__height".into(), json!(false)); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!((bounds(&app, a).width(), bounds(&app, a).height()), (30.0, 10.0)); + assert_eq!((bounds(&app, b).width(), bounds(&app, b).height()), (30.0, 40.0)); +} + +#[test] +fn replace_opens_with_the_scale_strokes_preference_and_swap_runs_directly() { + let (mut app, a, b) = app(); + app.run("prefs.set", json!({"key": "scaleStrokes", "value": true})).unwrap(); + click(&mut app, "Replace with Top Object…"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), &d.fields["scaleStrokes"], &d.fields["fit"]), (REPLACE, &json!(true), &json!("keep"))); + crate::dialogs::cancel(&mut app); + click(&mut app, "Swap Objects"); + assert!(app.ui.dialog.is_none()); + assert_eq!( + (bounds(&app, a).center(), bounds(&app, b).center()), + (vectorcraft_geom::Point::new(120.0, 120.0), vectorcraft_geom::Point::new(10.0, 5.0)) + ); +} + +#[test] +fn zoom_to_selection_fits_the_selection_in_the_view() { + let (mut app, _, b) = app(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(560.0, 460.0))); + app.run("select.set", json!({"ids": [b.0]})).unwrap(); + assert!(menus::enabled(&app, ZOOM)); + app.run(ZOOM, json!({})).unwrap(); + let v = app.view().unwrap(); + // 40 × 40 into 500 × 400 (the window less the margin). + assert_eq!((v.center, v.zoom), (vectorcraft_geom::Point::new(120.0, 120.0), 10.0)); + app.run("select.none", json!({})).unwrap(); + assert!(!menus::enabled(&app, ZOOM)); + assert!(app.run(ZOOM, json!({})).is_err()); +} diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index add1716110..b25f36b9dc 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -1,7 +1,7 @@ use super::drag::{drag_id, drop_press}; use super::{CornerRadius, Drag, Point, Pos2, Rect, SCROLL_BAR, Sense, Stroke, Tokens, Ui, VectorcraftApp, View, Xf, pos2, vec2}; -/// Fit the view (View → Fit Artboard / Fit All / Actual Size). +/// Fit the view (View → Fit Artboard / Fit All / Actual Size, Arrange Tools › Zoom to Selection). pub fn fit(app: &mut VectorcraftApp, how: &str) { let Some(rect) = app.canvas_rect else { if let Some(v) = app.view_mut() { @@ -15,6 +15,7 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) { "view.fitAll" => { st.doc.art_bounds().map(|a| st.doc.artboards.iter().fold(a, |r, ab| r.union(ab.rect))).or(st.doc.artboards.first().map(|a| a.rect)) } + crate::addons::arrange::ZOOM => st.doc.bounds_of(&st.selection.objects, true), // The navigator's artboard (the first if it has gone). _ => st.doc.artboards.get(current).or(st.doc.artboards.first()).map(|a| a.rect), }; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index acbad17508..83e7850f4e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -39,7 +39,7 @@ pub mod find_links; pub mod flare_options; pub mod flatten; pub mod flattener_presets; -mod form; +pub(crate) mod form; pub(crate) mod free_distort; pub mod freehand; mod gradient_stop; @@ -387,6 +387,7 @@ registry! { PathfinderOptions: [pathfinder_options::KIND] => pathfinder_options::SPEC, RearrangeArtboards: [rearrange_artboards::KIND] => rearrange_artboards::SPEC, PathToolOptions: [crate::addons::pathtools::KIND] => crate::addons::pathtools::OPTIONS_SPEC, + ArrangeTools: [crate::addons::arrange::GRID, crate::addons::arrange::REPLACE, crate::addons::arrange::SIZE] => crate::addons::arrange::SPEC, ShortcutImport: [shortcut_import::KIND] => DialogSpec::window(shortcut_import::show, shortcut_import::confirm), } diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index ce01777507..ed6be47e86 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -49,7 +49,7 @@ pub(super) fn length(ui: &mut egui::Ui, d: &mut Dialog, key: &str, unit: Unit, w /// [`length`] whose value is kept within `range` (points): a value typed outside it becomes the /// nearer end, so the field shows the distance the command applies (#4637). -pub(super) fn length_within( +pub(crate) fn length_within( ui: &mut egui::Ui, d: &mut Dialog, key: &str, @@ -247,7 +247,7 @@ pub(super) fn text_area_named(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name } /// A checkbox bound to `d.fields[key]` (its box shows while unchecked too). -pub(super) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { +pub(crate) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { let b = d.bool(key); if crate::widgets::check(ui, label, b, true) { d.fields.insert(key.into(), Value::Bool(!b)); @@ -256,7 +256,7 @@ pub(super) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { /// A dropdown row bound to the string `d.fields[key]`: `label` in a column `widths.0` wide, the /// dropdown `widths.1` wide; `options` are (value, label). -pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, widths: (f32, f32), options: &[(&str, &str)]) { +pub(crate) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, widths: (f32, f32), options: &[(&str, &str)]) { crate::widgets::label_row(ui, label, widths.0, |ui| dropdown(ui, d, key, widths.1, options, false)); } @@ -339,7 +339,7 @@ pub(super) fn grid_then(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit, more: imp } /// Command/effect parameters from the dialog fields (drop UI-only keys). -pub(super) fn params(d: &Dialog) -> Value { +pub(crate) fn params(d: &Dialog) -> Value { Value::Object(d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect()) } @@ -817,7 +817,7 @@ pub(super) const PREVIEWED: &str = "__previewed"; /// The Preview checkbox of a dialog that previews `cmd` on the canvas: while it is on, `params` run /// again on the interaction's snapshot whenever they differ from the last preview (whether a widget /// or `ui.dialog.set` changed them); turning it off rolls back. -pub(super) fn preview(app: &mut crate::VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, label: &str, cmd: &str, params: Value) { +pub(crate) fn preview(app: &mut crate::VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, label: &str, cmd: &str, params: Value) { let _ = preview_result(app, ui, d, label, cmd, params); } @@ -866,7 +866,7 @@ pub(super) fn preview_now(app: &mut crate::VectorcraftApp, d: &mut Dialog, label /// OK of a dialog using [`preview`]: keep the previewed `cmd` as one undo step (or run it when no /// preview runs) and close; the command's warning shows in the status bar either way. On an error /// the dialog stays open. -pub(super) fn commit_preview(app: &mut crate::VectorcraftApp, cmd: &str, params: Value) -> Result<Value, String> { +pub(crate) fn commit_preview(app: &mut crate::VectorcraftApp, cmd: &str, params: Value) -> Result<Value, String> { let r = if app.session.in_interaction() { match app.session.preview(cmd, ¶ms) { Ok(v) => { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 99f0bbc2d8..5b65923a19 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5891,3 +5891,15 @@ breakText Glyphs Глифи Gap 4 Разстояние 4 Dash 5 Тире 5 Gap 5 Разстояние 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Инструменти за подреждане + Arrange in Grid… Подреждане в мрежа… + Arrange in Grid Подреждане в мрежа + Swap Objects Размяна на обекти + Swap Objects and Sizes Размяна на обекти и размери + Replace with Top Object… Замяна с горния обект… + Replace with Top Object Замяна с горния обект + Set Size of Each… Задаване на размер на всеки… + Set Size of Each Задаване на размер на всеки +@msg select exactly two objects изберете точно два обекта +@msg give `width` or `height` задайте `width` или `height` diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 700fd9ddee..5798a7a798 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5924,3 +5924,15 @@ shape Open Otvoren @msg `follow` must be "average", "a" or "b" `follow` mora biti "average", "a" ili "b" @msg the paths have nothing to merge there putanje tu nemaju šta spojiti @msg the path is locked or hidden putanja je zaključana ili skrivena +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Alati za raspoređivanje + Arrange in Grid… Rasporedi u mrežu… + Arrange in Grid Rasporedi u mrežu + Swap Objects Zamijeni objekte + Swap Objects and Sizes Zamijeni objekte i veličine + Replace with Top Object… Zamijeni gornjim objektom… + Replace with Top Object Zamijeni gornjim objektom + Set Size of Each… Postavi veličinu svakog… + Set Size of Each Postavi veličinu svakog +@msg select exactly two objects odaberite tačno dva objekta +@msg give `width` or `height` zadajte `width` ili `height` diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index dc8fc7effd..d93092418f 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5991,3 +5991,15 @@ breakText Glyphs Glifs Gap 4 Separació 4 Dash 5 Guió 5 Gap 5 Separació 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Eines d’organització + Arrange in Grid… Organitza en quadrícula… + Arrange in Grid Organitza en quadrícula + Swap Objects Intercanvia els objectes + Swap Objects and Sizes Intercanvia els objectes i les mides + Replace with Top Object… Reemplaça per l’objecte superior… + Replace with Top Object Reemplaça per l’objecte superior + Set Size of Each… Defineix la mida de cadascun… + Set Size of Each Defineix la mida de cadascun +@msg select exactly two objects selecciona exactament dos objectes +@msg give `width` or `height` indica `width` o `height` diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5498e35481..50a8f953e1 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6000,3 +6000,15 @@ breakText Glyphs Glyfy Gap 4 Mezera 4 Dash 5 Čárka 5 Gap 5 Mezera 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Nástroje uspořádání + Arrange in Grid… Uspořádat do mřížky… + Arrange in Grid Uspořádat do mřížky + Swap Objects Prohodit objekty + Swap Objects and Sizes Prohodit objekty a velikosti + Replace with Top Object… Nahradit horním objektem… + Replace with Top Object Nahradit horním objektem + Set Size of Each… Nastavit velikost jednotlivě… + Set Size of Each Nastavit velikost jednotlivě +@msg select exactly two objects vyberte právě dva objekty +@msg give `width` or `height` zadejte `width` nebo `height` diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index c4e19d9939..87249badf8 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5905,3 +5905,15 @@ breakText Glyphs Glyffer Gap 4 Mellemrum 4 Dash 5 Streg 5 Gap 5 Mellemrum 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Arrangeringsværktøjer + Arrange in Grid… Arrangér i gitter… + Arrange in Grid Arrangér i gitter + Swap Objects Byt objekter + Swap Objects and Sizes Byt objekter og størrelser + Replace with Top Object… Erstat med øverste objekt… + Replace with Top Object Erstat med øverste objekt + Set Size of Each… Angiv størrelse for hver… + Set Size of Each Angiv størrelse for hver +@msg select exactly two objects markér præcis to objekter +@msg give `width` or `height` angiv `width` eller `height` diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 87ac4fc701..513ff8e4f4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5586,3 +5586,15 @@ breakText Glyphs Glyphen Gap 4 Lücke 4 Dash 5 Strich 5 Gap 5 Lücke 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Anordnungswerkzeuge + Arrange in Grid… In Raster anordnen… + Arrange in Grid In Raster anordnen + Swap Objects Objekte vertauschen + Swap Objects and Sizes Objekte und Größen vertauschen + Replace with Top Object… Durch oberstes Objekt ersetzen… + Replace with Top Object Durch oberstes Objekt ersetzen + Set Size of Each… Größe einzeln festlegen… + Set Size of Each Größe einzeln festlegen +@msg select exactly two objects wählen Sie genau zwei Objekte aus +@msg give `width` or `height` geben Sie `width` oder `height` an diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c7c2ab8980..ce282989f0 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5953,3 +5953,15 @@ breakText Glyphs Γλύφοι Gap 4 Διάκενο 4 Dash 5 Παύλα 5 Gap 5 Διάκενο 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Εργαλεία διάταξης + Arrange in Grid… Διάταξη σε πλέγμα… + Arrange in Grid Διάταξη σε πλέγμα + Swap Objects Εναλλαγή αντικειμένων + Swap Objects and Sizes Εναλλαγή αντικειμένων και μεγεθών + Replace with Top Object… Αντικατάσταση με το επάνω αντικείμενο… + Replace with Top Object Αντικατάσταση με το επάνω αντικείμενο + Set Size of Each… Ορισμός μεγέθους καθενός… + Set Size of Each Ορισμός μεγέθους καθενός +@msg select exactly two objects επιλέξτε ακριβώς δύο αντικείμενα +@msg give `width` or `height` δώστε `width` ή `height` diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b517c4c791..fe945423e6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5943,3 +5943,15 @@ breakText Glyphs Glifos Gap 4 Hueco 4 Dash 5 Guion 5 Gap 5 Hueco 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Herramientas de organización + Arrange in Grid… Organizar en cuadrícula… + Arrange in Grid Organizar en cuadrícula + Swap Objects Intercambiar objetos + Swap Objects and Sizes Intercambiar objetos y tamaños + Replace with Top Object… Reemplazar por el objeto superior… + Replace with Top Object Reemplazar por el objeto superior + Set Size of Each… Definir el tamaño de cada uno… + Set Size of Each Definir el tamaño de cada uno +@msg select exactly two objects selecciona exactamente dos objetos +@msg give `width` or `height` indica `width` o `height` diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 92f4e48945..7ebd23d6bc 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6031,3 +6031,15 @@ breakText Glyphs Merkit Gap 4 Väli 4 Dash 5 Viiva 5 Gap 5 Väli 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Järjestelytyökalut + Arrange in Grid… Järjestä ruudukkoon… + Arrange in Grid Järjestä ruudukkoon + Swap Objects Vaihda objektien paikat + Swap Objects and Sizes Vaihda objektien paikat ja koot + Replace with Top Object… Korvaa ylimmällä objektilla… + Replace with Top Object Korvaa ylimmällä objektilla + Set Size of Each… Aseta kunkin koko… + Set Size of Each Aseta kunkin koko +@msg select exactly two objects valitse täsmälleen kaksi objektia +@msg give `width` or `height` anna `width` tai `height` diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 27a2d52010..859b2bfcd3 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5999,3 +5999,15 @@ breakText Glyphs Glyphes Gap 4 Écart 4 Dash 5 Tiret 5 Gap 5 Écart 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Outils de disposition + Arrange in Grid… Disposer en grille… + Arrange in Grid Disposer en grille + Swap Objects Permuter les objets + Swap Objects and Sizes Permuter les objets et leurs tailles + Replace with Top Object… Remplacer par l’objet supérieur… + Replace with Top Object Remplacer par l’objet supérieur + Set Size of Each… Définir la taille de chaque objet… + Set Size of Each Définir la taille de chaque objet +@msg select exactly two objects sélectionnez exactement deux objets +@msg give `width` or `height` indiquez `width` ou `height` diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index ec63473054..02e7303ed4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5987,3 +5987,15 @@ breakText Glyphs Glifák Gap 4 Hézag 4 Dash 5 Vonal 5 Gap 5 Hézag 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Elrendezőeszközök + Arrange in Grid… Elrendezés rácsba… + Arrange in Grid Elrendezés rácsba + Swap Objects Objektumok cseréje + Swap Objects and Sizes Objektumok és méretek cseréje + Replace with Top Object… Csere a legfelső objektumra… + Replace with Top Object Csere a legfelső objektumra + Set Size of Each… Egyenkénti méret beállítása… + Set Size of Each Egyenkénti méret beállítása +@msg select exactly two objects jelölj ki pontosan két objektumot +@msg give `width` or `height` add meg a `width` vagy a `height` értéket diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f958f41b14..c2d3695755 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5905,3 +5905,15 @@ breakText Glyphs Glif Gap 4 Jarak 4 Dash 5 Garis 5 Gap 5 Jarak 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Alat penyusunan + Arrange in Grid… Susun dalam grid… + Arrange in Grid Susun dalam grid + Swap Objects Tukar objek + Swap Objects and Sizes Tukar objek dan ukuran + Replace with Top Object… Ganti dengan objek teratas… + Replace with Top Object Ganti dengan objek teratas + Set Size of Each… Atur ukuran masing-masing… + Set Size of Each Atur ukuran masing-masing +@msg select exactly two objects pilih tepat dua objek +@msg give `width` or `height` berikan `width` atau `height` diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a326cf55fb..8021409d21 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5620,3 +5620,15 @@ breakText Glyphs Glifi Gap 4 Spazio 4 Dash 5 Tratto 5 Gap 5 Spazio 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Strumenti di disposizione + Arrange in Grid… Disponi in griglia… + Arrange in Grid Disponi in griglia + Swap Objects Scambia oggetti + Swap Objects and Sizes Scambia oggetti e dimensioni + Replace with Top Object… Sostituisci con l’oggetto superiore… + Replace with Top Object Sostituisci con l’oggetto superiore + Set Size of Each… Imposta dimensione di ciascuno… + Set Size of Each Imposta dimensione di ciascuno +@msg select exactly two objects seleziona esattamente due oggetti +@msg give `width` or `height` indica `width` o `height` diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 50afc7d72b..269651408f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5686,3 +5686,15 @@ breakText Glyphs 字形 Gap 4 間隔 4 Dash 5 線分 5 Gap 5 間隔 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools 配置ツール + Arrange in Grid… グリッドに配置… + Arrange in Grid グリッドに配置 + Swap Objects オブジェクトを入れ替え + Swap Objects and Sizes オブジェクトとサイズを入れ替え + Replace with Top Object… 最前面のオブジェクトで置き換え… + Replace with Top Object 最前面のオブジェクトで置き換え + Set Size of Each… 個別にサイズを設定… + Set Size of Each 個別にサイズを設定 +@msg select exactly two objects オブジェクトをちょうど 2 つ選択してください +@msg give `width` or `height` `width` または `height` を指定してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 8b52357ad9..fd0eaae44a 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5990,3 +5990,15 @@ breakText Glyphs გლიფები Gap 4 შუალედი 4 Dash 5 ტირე 5 Gap 5 შუალედი 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools განლაგების ხელსაწყოები + Arrange in Grid… ბადეში განლაგება… + Arrange in Grid ბადეში განლაგება + Swap Objects ობიექტების გაცვლა + Swap Objects and Sizes ობიექტებისა და ზომების გაცვლა + Replace with Top Object… ზედა ობიექტით ჩანაცვლება… + Replace with Top Object ზედა ობიექტით ჩანაცვლება + Set Size of Each… თითოეულის ზომის დაყენება… + Set Size of Each თითოეულის ზომის დაყენება +@msg select exactly two objects მონიშნეთ ზუსტად ორი ობიექტი +@msg give `width` or `height` მიუთითეთ `width` ან `height` diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index d1ebee17d3..00666a8401 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5583,3 +5583,15 @@ breakText Glyphs 글리프 Gap 4 간격 4 Dash 5 대시 5 Gap 5 간격 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools 배치 도구 + Arrange in Grid… 격자로 배치… + Arrange in Grid 격자로 배치 + Swap Objects 개체 맞바꾸기 + Swap Objects and Sizes 개체와 크기 맞바꾸기 + Replace with Top Object… 맨 위 개체로 바꾸기… + Replace with Top Object 맨 위 개체로 바꾸기 + Set Size of Each… 각각 크기 설정… + Set Size of Each 각각 크기 설정 +@msg select exactly two objects 개체를 정확히 두 개 선택하세요 +@msg give `width` or `height` `width` 또는 `height`를 지정하세요 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 334707edc5..f04ea4da45 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5949,3 +5949,15 @@ breakText Glyphs Glyphs Gap 4 Tussenruimte 4 Dash 5 Streepje 5 Gap 5 Tussenruimte 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Schikgereedschappen + Arrange in Grid… In raster schikken… + Arrange in Grid In raster schikken + Swap Objects Objecten omwisselen + Swap Objects and Sizes Objecten en formaten omwisselen + Replace with Top Object… Vervangen door bovenste object… + Replace with Top Object Vervangen door bovenste object + Set Size of Each… Formaat van elk instellen… + Set Size of Each Formaat van elk instellen +@msg select exactly two objects selecteer precies twee objecten +@msg give `width` or `height` geef `width` of `height` op diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 49d549c17b..17810fe501 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5856,3 +5856,15 @@ breakText Glyphs Glify Gap 4 Odstęp 4 Dash 5 Kreska 5 Gap 5 Odstęp 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Narzędzia układania + Arrange in Grid… Ułóż w siatce… + Arrange in Grid Ułóż w siatce + Swap Objects Zamień obiekty miejscami + Swap Objects and Sizes Zamień obiekty miejscami i rozmiarami + Replace with Top Object… Zastąp górnym obiektem… + Replace with Top Object Zastąp górnym obiektem + Set Size of Each… Ustaw rozmiar każdego… + Set Size of Each Ustaw rozmiar każdego +@msg select exactly two objects zaznacz dokładnie dwa obiekty +@msg give `width` or `height` podaj `width` lub `height` diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 26d60b4234..e1fc3b3e6d 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5920,3 +5920,15 @@ breakText Glyphs Glifos Gap 4 Lacuna 4 Dash 5 Traço 5 Gap 5 Lacuna 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Ferramentas de Organização + Arrange in Grid… Organizar em Grade… + Arrange in Grid Organizar em Grade + Swap Objects Trocar Objetos + Swap Objects and Sizes Trocar Objetos e Tamanhos + Replace with Top Object… Substituir pelo Objeto Superior… + Replace with Top Object Substituir pelo Objeto Superior + Set Size of Each… Definir Tamanho de Cada Um… + Set Size of Each Definir Tamanho de Cada Um +@msg select exactly two objects selecione exatamente dois objetos +@msg give `width` or `height` informe `width` ou `height` diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2fec550643..412d4d129b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5978,3 +5978,15 @@ breakText Glyphs Глифы Gap 4 Зазор 4 Dash 5 Штрих 5 Gap 5 Зазор 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Инструменты размещения + Arrange in Grid… Разместить по сетке… + Arrange in Grid Разместить по сетке + Swap Objects Поменять объекты местами + Swap Objects and Sizes Поменять объекты местами и размерами + Replace with Top Object… Заменить верхним объектом… + Replace with Top Object Заменить верхним объектом + Set Size of Each… Задать размер каждого… + Set Size of Each Задать размер каждого +@msg select exactly two objects выделите ровно два объекта +@msg give `width` or `height` укажите `width` или `height` diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fd253ed7a9..08b0b8105c 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6053,3 +6053,15 @@ breakText Glyphs Glyfer Gap 4 Mellanrum 4 Dash 5 Streck 5 Gap 5 Mellanrum 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Ordningsverktyg + Arrange in Grid… Ordna i rutnät… + Arrange in Grid Ordna i rutnät + Swap Objects Byt plats på objekt + Swap Objects and Sizes Byt plats och storlek på objekt + Replace with Top Object… Ersätt med översta objektet… + Replace with Top Object Ersätt med översta objektet + Set Size of Each… Ange storlek för vart och ett… + Set Size of Each Ange storlek för vart och ett +@msg select exactly two objects markera exakt två objekt +@msg give `width` or `height` ange `width` eller `height` diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 849c9c74f5..fff0356575 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5958,3 +5958,15 @@ breakText Glyphs Glifler Gap 4 Boşluk 4 Dash 5 Çizgi 5 Gap 5 Boşluk 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Düzenleme Araçları + Arrange in Grid… Izgarada Düzenle… + Arrange in Grid Izgarada Düzenle + Swap Objects Nesnelerin Yerini Değiştir + Swap Objects and Sizes Nesnelerin Yerini ve Boyutunu Değiştir + Replace with Top Object… En Üstteki Nesneyle Değiştir… + Replace with Top Object En Üstteki Nesneyle Değiştir + Set Size of Each… Her Birinin Boyutunu Ayarla… + Set Size of Each Her Birinin Boyutunu Ayarla +@msg select exactly two objects tam olarak iki nesne seç +@msg give `width` or `height` `width` veya `height` belirt diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 9f06a0f787..1c6f93e5d0 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5600,3 +5600,15 @@ breakText Glyphs Гліфи Gap 4 Проміжок 4 Dash 5 Штрих 5 Gap 5 Проміжок 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Інструменти розміщення + Arrange in Grid… Розмістити сіткою… + Arrange in Grid Розмістити сіткою + Swap Objects Поміняти об’єкти місцями + Swap Objects and Sizes Поміняти об’єкти місцями та розмірами + Replace with Top Object… Замінити верхнім об’єктом… + Replace with Top Object Замінити верхнім об’єктом + Set Size of Each… Задати розмір кожного… + Set Size of Each Задати розмір кожного +@msg select exactly two objects виділіть рівно два об’єкти +@msg give `width` or `height` укажіть `width` або `height` diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 814b2bd0d8..99d8437f89 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5602,3 +5602,15 @@ breakText Glyphs Glyph Gap 4 Khoảng hở 4 Dash 5 Nét 5 Gap 5 Khoảng hở 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Công cụ sắp xếp + Arrange in Grid… Sắp xếp theo lưới… + Arrange in Grid Sắp xếp theo lưới + Swap Objects Hoán đổi đối tượng + Swap Objects and Sizes Hoán đổi đối tượng và kích thước + Replace with Top Object… Thay bằng đối tượng trên cùng… + Replace with Top Object Thay bằng đối tượng trên cùng + Set Size of Each… Đặt kích thước từng cái… + Set Size of Each Đặt kích thước từng cái +@msg select exactly two objects hãy chọn đúng hai đối tượng +@msg give `width` or `height` hãy cho `width` hoặc `height` diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 8fa91dcae0..6ecaf09308 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5842,3 +5842,15 @@ breakText Glyphs 字形 Gap 4 间隔 4 Dash 5 虚线 5 Gap 5 间隔 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools 排列工具 + Arrange in Grid… 按网格排列… + Arrange in Grid 按网格排列 + Swap Objects 交换对象 + Swap Objects and Sizes 交换对象和大小 + Replace with Top Object… 替换为顶层对象… + Replace with Top Object 替换为顶层对象 + Set Size of Each… 分别设置大小… + Set Size of Each 分别设置大小 +@msg select exactly two objects 请恰好选择两个对象 +@msg give `width` or `height` 请提供 `width` 或 `height` diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8525483188..75ee3a4134 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5639,3 +5639,15 @@ breakText Glyphs 字符 Gap 4 間隙 4 Dash 5 虛線 5 Gap 5 間隙 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools 排列工具 + Arrange in Grid… 依格狀排列… + Arrange in Grid 依格狀排列 + Swap Objects 對調物件 + Swap Objects and Sizes 對調物件和大小 + Replace with Top Object… 以最上層物件取代… + Replace with Top Object 以最上層物件取代 + Set Size of Each… 個別設定大小… + Set Size of Each 個別設定大小 +@msg select exactly two objects 請選取正好兩個物件 +@msg give `width` or `height` 請提供 `width` 或 `height` diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 382c730aa1..70c8639710 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -37,6 +37,9 @@ fn queried_dialog(id: &str) -> Option<(&'static str, &'static [&'static str])> { /// The dialog (kind, fields) the menu item of command `id` opens, as the reference app's does. pub(super) fn menu_dialog(app: &VectorcraftApp, id: &str) -> Option<(&'static str, Value)> { + if let Some(found) = crate::addons::arrange::dialog(app, id) { + return Some(found); + } Some(match id { // Move opens on the offset moved last and the checkboxes it was last confirmed with. "object.move" => ("move", crate::dialogs::transform::move_fields(app)), diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 772d5a8e19..bb20fbbca5 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -529,6 +529,7 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result< }, crate::addons::pathtools::corner::COMMAND => crate::addons::pathtools::corner::open(app), crate::addons::pathtools::shared_edge::COMMAND => crate::addons::pathtools::shared_edge::open(app), + crate::addons::arrange::ZOOM => crate::addons::arrange::zoom(app), "tool.setOption" => app.session.set_tool_option_cmd(p), "effect.dialog" => crate::dialogs::open_effect_dialog(app, p), "ui.recolorDialog" => crate::dialogs::recolor::open(app, p), diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 55ee84b800..c1cdf4f59d 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -206,7 +206,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { | "view.fitAll" | "view.actualSize" | "view.rotate" => app.session.active().is_some(), - "view.rotateToSelection" => app.session.active().is_some_and(|d| !d.selection.objects.is_empty()), + "view.rotateToSelection" | crate::addons::arrange::ZOOM => app.session.active().is_some_and(|d| !d.selection.objects.is_empty()), id if id.starts_with("file.openRecent") => recent_slot(app, id).is_some(), "file.clearRecent" => !app.ui.recent_files.is_empty(), id if id.starts_with("type.recentFont") => { diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index bdc0966164..ba3e700c62 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -12,6 +12,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} choose the Shared Edge Merge Tool and show the Anchor Inspector panel with its brush, coincidence and Keep Shared Points Together", ), + ( + crate::addons::arrange::ZOOM, + "Zoom to Selection", + "", + "{} zoom and centre the active view so the selection's visual bounds fill the window, with a margin (as view.fitArtboard fits the artboard); off with nothing selected", + ), ( "app.language", "Interface Language", @@ -922,7 +928,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.menuDialog", "Menu Dialog", "", - "{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|path.averageJoin|object.path.offsetPath|object.path.simplify|object.path.smooth|object.path.splitIntoGrid|object.vectorHalftone|artboard.rearrange|object.envelope.makeWithWarp|object.envelope.resetWithWarp|object.envelope.makeWithMesh|object.envelope.resetWithMesh|object.envelope.options} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …, vectorHalftone, rearrangeArtboards, envelopeWarp, envelopeMesh, envelopeOptions; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates; Transform Each opens on the values it was last confirmed with (not `copy`), and its Copy button confirms with `copy: true`; the envelope dialogs start from the selected envelope, Make opens as Reset (`reset: true`) while one is selected)", + "{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|path.averageJoin|object.path.offsetPath|object.path.simplify|object.path.smooth|object.path.splitIntoGrid|object.vectorHalftone|artboard.rearrange|object.envelope.makeWithWarp|object.envelope.resetWithWarp|object.envelope.makeWithMesh|object.envelope.resetWithMesh|object.envelope.options|addon.arrange.grid|addon.arrange.replace|addon.arrange.size} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …, vectorHalftone, rearrangeArtboards, envelopeWarp, envelopeMesh, envelopeOptions, arrangeGrid, arrangeReplace, arrangeSize; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates; Transform Each opens on the values it was last confirmed with (not `copy`), and its Copy button confirms with `copy: true`; the envelope dialogs start from the selected envelope, Make opens as Reset (`reset: true`) while one is selected)", ), ( "ui.widthPointEdit", diff --git a/docs/mcp.md b/docs/mcp.md index c1568ae63c..09d114cf0b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6160,3 +6160,29 @@ one, out of range values and a request with no fix on. ```json {"name":"run_command","arguments":{"command":"addon.color.printFixes","params":{"scope":"document","inkLimit":300,"removeWhiteOverprint":true,"overprintBlack":100}}} ``` + +## Arrange Tools add-on + +Arrange Tools is a built-in add-on that lays out, swaps, replaces and sizes selected objects: its commands are +`addon.arrange.*`, and in the desktop app it lives in Object › Plug-ins › Arrange Tools. Each command acts on the +top-level selected objects and is one undo step; a run that moves nothing makes no step. Places and sizes are measured +as `object.setBounds` measures them (Use Preview Bounds), except Arrange in Grid, which uses visual bounds. + +| Command | Parameters (default) | Does | +|---|---|---| +| `addon.arrange.grid` | `layout`: `grid`, `row`, `column` (`grid`); `columns`: 1…10000 (⌈√count⌉, never more than the count); `gapX` pt (10), `gapY` pt (`gapX`), each −1000000…1000000; `centerOnArtboard` (false) | Lays the objects out in their stacking order from the back, left to right and top to bottom. Each cell is as wide as the widest object in its column and as tall as the tallest in its row, and each object is centred in its cell. The grid starts at the selection's top-left, or is centred on the artboard under the selection's centre (the first artboard when none is there). → `{count}` | +| `addon.arrange.swap` | `size` (false); `scaleStrokes` (the Scale Strokes & Effects preference) | Swaps the bounds centres of exactly two selected objects (off otherwise); with `size`, each also takes the other's width and height. | +| `addon.arrange.replace` | `fit`: `keep`, `bounds` (the replaced object's width and height), `proportional` (fits inside them) (`keep`); `scaleStrokes`; `keep` (false) | Puts a copy of the topmost selected object centred on each of the other selected objects, in that object's place in the stacking order; the replaced objects are deleted unless `keep` (the copy then goes right above each). Needs two objects or more; the copies are selected. → `{ids}` | +| `addon.arrange.size` | `width`, `height` pt, 0.001…1000000 (at least one); `proportional` (false: a side not given stays; true: it follows the given one in proportion); `reference` 0…8 (4, the centre); `scaleStrokes` | Gives every selected object that width and height, keeping its reference point in place (the 9-point grid row by row from the top-left). | + +Zoom to Selection is the UI command `addon.arrange.zoomSelection {}` (remote mode only): it zooms and centres the +active view so the selection's visual bounds fill the window with a margin, as `view.fitArtboard` fits the artboard, +and fails with nothing selected. Arrange in Grid…, Replace with Top Object… and Set Size of Each… open dialogs +(`ui.menuDialog {command}`, dialog kinds `arrangeGrid`, `arrangeReplace` and `arrangeSize`) whose fields are the +command's parameters, with Preview; Set Size of Each's `__width` and `__height` say which sizes OK applies. + +```json +{"name":"run_command","arguments":{"command":"addon.arrange.grid","params":{"columns":4,"gapX":12,"gapY":12}}} +{"name":"run_command","arguments":{"command":"addon.arrange.replace","params":{"fit":"proportional"}}} +{"name":"run_command","arguments":{"command":"addon.arrange.size","params":{"width":72,"proportional":true}}} +``` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 38d9ba8b6b..639e286939 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Export for Screens laid out as Illustrator's, with Export PDFs As and sub-folders by scale or format; previously: Artwork Cleanup add-on: Join Point Text, #4826; scores unchanged; previously: G13: Direct Selection snaps a dragged line end square to straight paths and grazing curves; Path Precision Tools add-on: Shared Edge Merge Tool; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Arrange Tools add-on; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -1236,7 +1236,7 @@ applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansi ## Artwork Cleanup add-on (2026-10-30) -A VectorCraft addition, not an Illustrator feature, so no area score moves. Object › Plug-ins › Artwork Cleanup and its +A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Artwork Cleanup and its panel run `addon.cleanup.*`: Simplify Anchors (Keep Shape) on the shared kernel `vectorcraft_pathops::addons::keep_shape`, a health check of 16 kinds of issue on the selection or the document with Show, Fix Selected and Fix All (each one undo step), Deep Ungroup and Rejoin Path Pieces. Tests: `addons::` in @@ -1255,7 +1255,7 @@ refusals), `addons::cleanup::tests` in `vectorcraft-ui-egui` (the submenu and th ## Path Precision Tools add-on (2026-10-30) -A VectorCraft addition, not an Illustrator feature, so no area score moves. Object › Plug-ins › Path Precision Tools, +A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Path Precision Tools, its Anchor Inspector panel and three tools kept out of the shipped toolbars (listed in the All Tools drawer) run `addon.pathtools.*`: an Anchor Eraser Brush on the shared keep-shape kernel, an Extend Path tool (straight, stretched curve or arc; shortening back along the path), a Measure & Inspect tool with hover readouts (lengths, area, curvature, @@ -1318,7 +1318,7 @@ ends), `addons::shared_edge` in `vectorcraft-tools`, `cmd::addons::pathtools::sh ## Smart Pen add-on (2026-10-30) -A VectorCraft addition, not an Illustrator feature, so no area score moves. Object › Plug-ins › Smart Pen selects the +A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Smart Pen selects the Smart Pen Tool (kept out of the shipped toolbars, listed in the All Tools drawer, no shortcut) and opens its panel. The tool draws with a straight or curved next segment (a curve after a straight segment leaves it tangent), smooth drags with Alt, Shift and Space, Tab to switch a segment, a rubber band, closing, joining and continuing open ends; and edits @@ -1333,7 +1333,7 @@ through the session) and `addons::pen::tests` in `vectorcraft-ui-egui` (the subm ## Color Effects add-on (2026-10-30) -A VectorCraft addition, not an Illustrator feature, so no area score moves. Effect › Color Effects adds live Exposure +A VectorCraft addition, not a feature of the reference app, so no area score moves. Effect › Color Effects adds live Exposure (stops of linear light, offset and gamma), Desaturate and Invert Colors as colour adjustments evaluated with those of Effect › Color Adjustments (`vectorcraft_effects::addons::color`), and `addon.color.filter` applies any colour adjustment to the colours themselves through the Edit Colors pass. Object › Plug-ins › Color Effects and its panel reach them. @@ -1360,10 +1360,29 @@ tints, curves, multiplied inks, skip spot), `duotone_*` and `cmd::addons::color: params and RGB documents refused), and `the_duotone_dialog_has_a_row_per_tone_and_the_curve_of_one` and `print_ink_fixes_opens_its_options_and_runs_on_ok` in `vectorcraft-ui-egui`. Commands: `docs/mcp.md` › Color Effects add-on. +## Arrange Tools add-on (2026-10-31) + +A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Arrange Tools: Arrange in +Grid… lays the top-level selected objects out in their stacking order from the back, in rows and columns (⌈√count⌉ by +default), one row or one column, each centred in a cell as wide as its column's widest object and as tall as its row's +tallest (visual bounds), with horizontal and vertical gaps, from the selection's top-left or centred on the artboard; +Swap Objects swaps the bounds centres of two objects, and Swap Objects and Sizes their widths and heights too; Replace +with Top Object… puts a copy of the topmost selected object centred on each of the others, in its stacking place, at its +own size, the replaced object's size or fitted inside it in proportion, deleting or keeping the replaced objects; Set +Size of Each… gives every object an exact width and/or height (one side may follow in proportion) about one of the nine +reference points; Zoom to Selection fits the active view to the selection. Sizes and places are measured as +`object.setBounds` measures them (Use Preview Bounds), strokes and effects scale as Scale Strokes & Effects says, each +command is one undo step, and the three dialogs preview on the canvas. Tests: `cmd::addons::arrange::tests` in +`vectorcraft-engine` (grid, row, column and artboard centring, visual bounds, swap with and without sizes and stroke +scaling, replace with each fit and kept originals in stacking order, size with proportions and reference points, one +undo step, off states, bad params refused), `addons::arrange::tests` in `vectorcraft-ui-egui` (the submenu, the +dialogs, Preview and Cancel, Zoom to Selection) and `command_sweep`. Commands: `docs/mcp.md` › Arrange Tools add-on. + ## Revision history | Date | Change | Summary | |---|---|---| +| 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. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index afd2fac04d..add5e8fd75 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Export for Screens laid out as Illustrator's; previously: Properties: Blend Options in Quick Actions for one selected blend with any tool, #4817; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Arrange Tools add-on; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -134,7 +134,7 @@ tools, never checked side by side) isn't captured row by row. | 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 | | Isolation mode by double-click (groups and single objects), breadcrumbs, Esc to exit | done | Single paths, compound paths, images, meshes and placed files isolate too ([helpx: Isolate objects](https://helpx.adobe.com/africa/illustrator/desktop/manage-objects/select-objects/isolate-objects.html)) | -| "Magnetic" moves of selected anchors with a modifier (#955 request) | missing | not an Illustrator feature as documented; under discussion | +| "Magnetic" moves of selected anchors with a modifier (#955 request) | missing | not a feature of the reference app as documented; under discussion | | Coinciding anchors of overlapping objects: the selected object's anchor is taken, also under another object | done | #1508 | ## Smart guides and snapping @@ -326,16 +326,17 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Preferences › General › Constrain Angle | done | arrow keys and Shift drags along the turned axes; the Rectangle, Rounded Rectangle, Ellipse and grid tools, point and area type and the shape size dialogs draw along them with the bounding box turned; Shift-drawn polygons and stars keep the turned base orientation; the Line Segment tool's Shift steps count from it (#394, <https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>); arcs, spirals, flares and Pen paths are not turned | | Tabs panel: a stop drags from where the press landed, a stop just added included | done | #1458 | | Outline view keeps template layers in Preview, dimmed | done | #1486; a picture placed to be traced stays visible under the outlines | -| Smart Pen add-on (VectorCraft addition): Object › Plug-ins › Smart Pen selects the Smart Pen Tool, which the All Tools drawer lists under its own heading but neither toolbar shows as shipped (no shortcut), and opens the Smart Pen panel (next segment straight or curved; Remove Points Keep Shape, Make Smooth, Make Corner, Retract Handles, Close or Join). The tool draws (click: straight or curved segment, a curve after a straight one tangent; drag: smooth anchor, Alt breaks the handle, Shift 45°, Space moves it; Tab switches a segment; rubber band; close, join, continue) and edits without switching tools (click selects a point, drag moves it, Alt-click removes it keeping the shape, Shift+Alt-click deletes it, double-click toggles smooth/corner; handle drags with Shift and Alt; segment drags reshape, Alt-click adds a point) | done | `addon.pen.*`, `crates/tools/src/addons/smart_pen.rs`, `crates/ui-egui/src/addons/pen.rs` and their tests; not an Illustrator feature, so it is not scored | -| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its seven tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape, Corner Tool, Slide Anchor Along Path, Shared Edge Merge Tool), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; the Corner Tool cuts editable corners on any path (hover shows the anchor, a click uses its radius and kind, a drag sizes the corner, Shift whole values; a corner's marker selects it and loads its values, drags to resize, to 0 or double-clicked removes it; never past what fits; Alt-marquee cuts every anchor inside) with a Corners section in the Anchor Inspector (radius, kind, selected count, Apply to Selected Anchors); Slide Anchor Along Path drags an anchor along its curve with the segments beside it refitted, Shift snapping where the tangent is a multiple of 45°; the Shared Edge Merge Tool shows, over two paths touching at two coincident anchors, the two stretches and the edge merging them, a click merges them (half way; Shift the upper path, Alt the lower), and near anchors meet or an anchor one path lacks is added to match (Shift+Alt asks for this inside a stretch), with a Shared Edges section in the Anchor Inspector (brush diameter, coincidence 0 to 3 pt, Keep Shared Points Together, which takes coincident anchors along with the add-on's own anchor moves); a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/` and their tests; not an Illustrator feature, so it is not scored | -| Path Precision Tools add-on (VectorCraft addition): Effect › Path Effects lists its live effects (Dashes to Geometry, Add Anchors, Extend Path, Close Path, Smooth Anchors, Retract Handles, Reverse Direction, Simplify Anchors, Split at Anchors), each with the effect dialog's live Preview except Reverse Direction, which has no options, and listed in the Appearance panel like any effect | done | `addon.path.*`, `crates/effects/src/addons/path.rs`, `crates/ui-egui/src/addons/pathtools.rs` and their tests; not an Illustrator feature, so it is not scored | -| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its four tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/shape.rs` and their tests; not an Illustrator feature, so it is not scored | -| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its six tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape, Corner Tool, Slide Anchor Along Path), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; the Corner Tool cuts editable corners on any path (hover shows the anchor, a click uses its radius and kind, a drag sizes the corner, Shift whole values; a corner's marker selects it and loads its values, drags to resize, to 0 or double-clicked removes it; never past what fits; Alt-marquee cuts every anchor inside) with a Corners section in the Anchor Inspector (radius, kind, selected count, Apply to Selected Anchors); Slide Anchor Along Path drags an anchor along its curve with the segments beside it refitted, Shift snapping where the tangent is a multiple of 45°; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/` and their tests; not an Illustrator feature, so it is not scored | -| Smart Pen add-on (VectorCraft addition): Object › Plug-ins › Smart Pen selects the Smart Pen Tool, which the All Tools drawer lists under its own heading but neither toolbar shows as shipped (no shortcut), and opens the Smart Pen panel (next segment straight or curved; Remove Points Keep Shape, Make Smooth, Make Corner, Retract Handles, Close or Join). The tool draws (click: straight or curved segment, a curve after a straight one tangent; drag: smooth anchor, Alt breaks the handle, Shift 45°, Space moves it; Tab switches a segment; rubber band; close, join, continue) and edits without switching tools (click selects a point, drag moves it, Alt-click removes it keeping the shape, Shift+Alt-click deletes it, double-click toggles smooth/corner; handle drags with Shift and Alt; segment drags reshape, Alt-click adds a point); an Alt-drag away from paths selects the points inside a marquee | done | `addon.pen.*`, `crates/tools/src/addons/smart_pen.rs`, `crates/ui-egui/src/addons/pen.rs` and their tests; not an Illustrator feature, so it is not scored | -| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its three tools (Anchor Eraser Brush, Extend Path, Measure & Inspect), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and runs Add Points at / Split at Crossings; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs` and their tests; not an Illustrator feature, so it is not scored | -| Artwork Cleanup add-on (VectorCraft addition): Object › Plug-ins › Artwork Cleanup opens its panel, which checks the selection or the document for 16 kinds of issue, lists them with counts, and shows or fixes the picked rows or all of them, each fix one undo step; Simplify Anchors (Keep Shape), Rejoin Path Pieces and Deep Ungroup open their options first | done | `addon.cleanup.*`, `crates/ui-egui/src/addons/cleanup.rs` and its tests; not an Illustrator feature, so it is not scored | +| Arrange Tools add-on (VectorCraft addition): Object › Plug-ins › Arrange Tools runs Arrange in Grid… (grid by row, one row or one column; columns, H and V spacing, Center on Artboard; Preview), Swap Objects, Swap Objects and Sizes, Replace with Top Object… (Actual Size, Bounds or Bounds (Fit Proportionally); Keep Original; Scale Strokes & Effects; Preview), Set Size of Each… (Width and Height each ticked on or off, Constrain Width and Height Proportions with one side, the reference point, Scale Strokes & Effects; Preview) and Zoom to Selection, each one undo step | done | `addon.arrange.*`, `crates/engine/src/cmd/addons/arrange.rs`, `crates/ui-egui/src/addons/arrange.rs` and their tests; not a feature of the reference app, so it is not scored | +| Smart Pen add-on (VectorCraft addition): Object › Plug-ins › Smart Pen selects the Smart Pen Tool, which the All Tools drawer lists under its own heading but neither toolbar shows as shipped (no shortcut), and opens the Smart Pen panel (next segment straight or curved; Remove Points Keep Shape, Make Smooth, Make Corner, Retract Handles, Close or Join). The tool draws (click: straight or curved segment, a curve after a straight one tangent; drag: smooth anchor, Alt breaks the handle, Shift 45°, Space moves it; Tab switches a segment; rubber band; close, join, continue) and edits without switching tools (click selects a point, drag moves it, Alt-click removes it keeping the shape, Shift+Alt-click deletes it, double-click toggles smooth/corner; handle drags with Shift and Alt; segment drags reshape, Alt-click adds a point) | done | `addon.pen.*`, `crates/tools/src/addons/smart_pen.rs`, `crates/ui-egui/src/addons/pen.rs` and their tests; not a feature of the reference app, so it is not scored | +| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its seven tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape, Corner Tool, Slide Anchor Along Path, Shared Edge Merge Tool), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; the Corner Tool cuts editable corners on any path (hover shows the anchor, a click uses its radius and kind, a drag sizes the corner, Shift whole values; a corner's marker selects it and loads its values, drags to resize, to 0 or double-clicked removes it; never past what fits; Alt-marquee cuts every anchor inside) with a Corners section in the Anchor Inspector (radius, kind, selected count, Apply to Selected Anchors); Slide Anchor Along Path drags an anchor along its curve with the segments beside it refitted, Shift snapping where the tangent is a multiple of 45°; the Shared Edge Merge Tool shows, over two paths touching at two coincident anchors, the two stretches and the edge merging them, a click merges them (half way; Shift the upper path, Alt the lower), and near anchors meet or an anchor one path lacks is added to match (Shift+Alt asks for this inside a stretch), with a Shared Edges section in the Anchor Inspector (brush diameter, coincidence 0 to 3 pt, Keep Shared Points Together, which takes coincident anchors along with the add-on's own anchor moves); a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/` and their tests; not a feature of the reference app, so it is not scored | +| Path Precision Tools add-on (VectorCraft addition): Effect › Path Effects lists its live effects (Dashes to Geometry, Add Anchors, Extend Path, Close Path, Smooth Anchors, Retract Handles, Reverse Direction, Simplify Anchors, Split at Anchors), each with the effect dialog's live Preview except Reverse Direction, which has no options, and listed in the Appearance panel like any effect | done | `addon.path.*`, `crates/effects/src/addons/path.rs`, `crates/ui-egui/src/addons/pathtools.rs` and their tests; not a feature of the reference app, so it is not scored | +| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its four tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/shape.rs` and their tests; not a feature of the reference app, so it is not scored | +| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its six tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape, Corner Tool, Slide Anchor Along Path), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; the Corner Tool cuts editable corners on any path (hover shows the anchor, a click uses its radius and kind, a drag sizes the corner, Shift whole values; a corner's marker selects it and loads its values, drags to resize, to 0 or double-clicked removes it; never past what fits; Alt-marquee cuts every anchor inside) with a Corners section in the Anchor Inspector (radius, kind, selected count, Apply to Selected Anchors); Slide Anchor Along Path drags an anchor along its curve with the segments beside it refitted, Shift snapping where the tangent is a multiple of 45°; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/` and their tests; not a feature of the reference app, so it is not scored | +| Smart Pen add-on (VectorCraft addition): Object › Plug-ins › Smart Pen selects the Smart Pen Tool, which the All Tools drawer lists under its own heading but neither toolbar shows as shipped (no shortcut), and opens the Smart Pen panel (next segment straight or curved; Remove Points Keep Shape, Make Smooth, Make Corner, Retract Handles, Close or Join). The tool draws (click: straight or curved segment, a curve after a straight one tangent; drag: smooth anchor, Alt breaks the handle, Shift 45°, Space moves it; Tab switches a segment; rubber band; close, join, continue) and edits without switching tools (click selects a point, drag moves it, Alt-click removes it keeping the shape, Shift+Alt-click deletes it, double-click toggles smooth/corner; handle drags with Shift and Alt; segment drags reshape, Alt-click adds a point); an Alt-drag away from paths selects the points inside a marquee | done | `addon.pen.*`, `crates/tools/src/addons/smart_pen.rs`, `crates/ui-egui/src/addons/pen.rs` and their tests; not a feature of the reference app, so it is not scored | +| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its three tools (Anchor Eraser Brush, Extend Path, Measure & Inspect), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and runs Add Points at / Split at Crossings; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs` and their tests; not a feature of the reference app, so it is not scored | +| Artwork Cleanup add-on (VectorCraft addition): Object › Plug-ins › Artwork Cleanup opens its panel, which checks the selection or the document for 16 kinds of issue, lists them with counts, and shows or fixes the picked rows or all of them, each fix one undo step; Simplify Anchors (Keep Shape), Rejoin Path Pieces and Deep Ungroup open their options first | done | `addon.cleanup.*`, `crates/ui-egui/src/addons/cleanup.rs` and its tests; not a feature of the reference app, so it is not scored | | Artwork Cleanup add-on text tools (VectorCraft addition): the panel's Text row, which the panel can hide, and Object › Plug-ins › Artwork Cleanup set the alignment of point type keeping its place, break type into lines (Shift-click: paragraphs), words or glyphs, split type at the cursor, reset a text transform (Alt-click: and its horizontal scale), combine point type, remove line or paragraph breaks and replace missing fonts, each one undo step | done | `addon.cleanup.*` text commands, `crates/ui-egui/src/addons/cleanup.rs` and its tests; a VectorCraft addition, so it is not scored | -| Color Effects add-on (VectorCraft addition): live Exposure, Desaturate and Invert Colors in Effect › Color Effects and Object › Plug-ins › Color Effects; its panel picks a colour adjustment and its values and adds it as a live effect or applies it as a filter, into the colours, as one undo step; Select by Color Kind selects the objects that use spot colours, carry colour effects, or are RGB, CMYK or grayscale images; Vector Halftone draws an object as halftone dots live (the dots Object › Vector Halftone makes), which Expand Appearance and the exporters write as paths; Duotone prints each colour's tone with one to four inks (a tint of a spot tone's swatch, or the CMYK mix of several), live or as a filter, its dialog a row per tone and the picked tone's curve; Print Ink Fixes (a CMYK document's ink limit, white overprint removed, black overprint set) opens its options from the submenu and the panel's Inks | done | `addon.color.*`, `crates/ui-egui/src/addons/color.rs` and its tests; not an Illustrator feature, so it is not scored | +| Color Effects add-on (VectorCraft addition): live Exposure, Desaturate and Invert Colors in Effect › Color Effects and Object › Plug-ins › Color Effects; its panel picks a colour adjustment and its values and adds it as a live effect or applies it as a filter, into the colours, as one undo step; Select by Color Kind selects the objects that use spot colours, carry colour effects, or are RGB, CMYK or grayscale images; Vector Halftone draws an object as halftone dots live (the dots Object › Vector Halftone makes), which Expand Appearance and the exporters write as paths; Duotone prints each colour's tone with one to four inks (a tint of a spot tone's swatch, or the CMYK mix of several), live or as a filter, its dialog a row per tone and the picked tone's curve; Print Ink Fixes (a CMYK document's ink limit, white overprint removed, black overprint set) opens its options from the submenu and the panel's Inks | done | `addon.color.*`, `crates/ui-egui/src/addons/color.rs` and its tests; not a feature of the reference app, so it is not scored | ## Look and feel @@ -376,6 +377,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Arrange Tools add-on (a VectorCraft addition): Object › Plug-ins › Arrange Tools; scores and estimates unchanged | | 2026-10-31 | minor | Export for Screens is laid out as Illustrator's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged | | 2026-10-31 | minor | Properties: Quick Actions has Blend Options for one selected blend with any tool, not only the Blend tool, as the blending page describes (#4817); scores unchanged | | 2026-10-31 | minor | Smart guides and snapping: Snap to Perpendicular and Snap to Tangent for an existing line's end dragged with Direct Selection (#2530), so the tangent and perpendicular row is done; scores and estimates unchanged | From 427de74f376f892fd076545a7b0c42e4a75b6e9b Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Sat, 31 Oct 2026 21:46:47 +0700 Subject: [PATCH 94/98] Fix #4890: Alt with Direct Selection marquee-selects whole paths --- crates/tools/src/direct.rs | 31 ++++++++++++++++++++----------- crates/tools/src/direct/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index c27466935e..c1aae51a4e 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -8,7 +8,9 @@ //! Group Selection's clicks do), drag a direction handle to //! reshape (Shift keeps it at 45° steps round its anchor, Alt moves it alone; smart guides snap //! it), marquee to select anchors (Shift-drag toggles them: the selected ones inside are -//! deselected, the others selected), drag a corner widget of any path (a star, a pen path) to round its corners +//! deselected, the others selected; with Alt held at the press, as Group Selection for the drag, +//! it selects every object it touches, whole), drag a corner widget of any path (a star, a pen +//! path) to round its corners //! (the selected ones when anchors are selected; Alt-click cycles their kind, double-click opens //! the Corners dialog). //! Group Selection: click selects the leaf; each further click on it adds the next enclosing group; @@ -103,6 +105,9 @@ enum State { /// Group Selection, E pressed an odd number of times during the drag: the marquee selects /// only the objects that lie entirely inside it, whole. enclosed: bool, + /// Direct Selection with Alt held at the press, which makes it the Group Selection tool + /// for the drag: the marquee selects every object it touches, whole (#4890). + whole: bool, }, Corner(CornerDrag), /// Dragging a bracket of type on a path. @@ -756,7 +761,7 @@ impl Tool for DirectSelectionTool { if let Some(out) = self.press_hidden(cx, ev, p) { return out; } - self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false }; + self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false, whole: ev.mods.alt }; vec![] } (PointerKind::Drag, State::MoveAnchors { start, grab, began }) => self.drag_move_anchors(cx, ev, p, (start, grab, began)), @@ -839,8 +844,8 @@ impl Tool for DirectSelectionTool { self.state = State::Idle; self.mesh.release(cx).unwrap_or_default() } - (PointerKind::Drag, State::Marquee { start, toggle, enclosed, .. }) => { - self.state = State::Marquee { start, cur: p, toggle, enclosed }; + (PointerKind::Drag, State::Marquee { start, toggle, enclosed, whole, .. }) => { + self.state = State::Marquee { start, cur: p, toggle, enclosed, whole }; vec![] } (PointerKind::Drag, State::Corner(mut c)) => { @@ -875,7 +880,7 @@ impl Tool for DirectSelectionTool { self.guides.clear(); vec![Action::Commit] } - (PointerKind::Up, State::Marquee { start, toggle, enclosed, .. }) => self.release_marquee(cx, p, (start, toggle, enclosed)), + (PointerKind::Up, State::Marquee { start, toggle, enclosed, whole, .. }) => self.release_marquee(cx, p, (start, toggle, enclosed, whole)), _ => vec![], } } @@ -913,6 +918,8 @@ impl Tool for DirectSelectionTool { match self.state { // Alt-dragging leaves a copy behind. State::MoveObject { began: true, .. } if m.alt => return Cursor::SelectionCopy, + // An Alt-marquee is Group Selection's until it is released. + State::Marquee { whole: true, .. } => return Cursor::GroupSelection, // Bending a segment keeps the cursor that offered it. State::Segment { began: true, .. } | State::SpineSegment { began: true, .. } => return Cursor::ReshapeSegment, // A drag shows the filled arrow, hollow while it snaps to a point or a guide. @@ -1102,7 +1109,7 @@ impl DirectSelectionTool { if let Some(out) = self.press_hidden(cx, ev, p) { return out; } - self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false }; + self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false, whole: false }; return vec![]; }; // A compound path's paths are picked one at a time, below the compound path (#2441). @@ -1381,7 +1388,7 @@ impl DirectSelectionTool { (false, None, None, None) => group.map(|g| Action::Exec("select.add".into(), json!({"ids": [g.0]}))).into_iter().collect(), } } - fn release_marquee(&mut self, cx: &ToolContext, p: Point, (start, toggle, enclosed): (Point, bool, bool)) -> Vec<Action> { + fn release_marquee(&mut self, cx: &ToolContext, p: Point, (start, toggle, enclosed, whole): (Point, bool, bool, bool)) -> Vec<Action> { self.state = State::Idle; let r = Rect::from_points(start, p); if r.width() < cx.tol(3.0) && r.height() < cx.tol(3.0) { @@ -1392,14 +1399,16 @@ impl DirectSelectionTool { return vec![Action::Exec("select.none".into(), json!({}))]; } // With Group Selection's enclosed marquee, the tool selects the objects inside it - // (members of groups too), each whole, as a click selects one. - if enclosed { + // (members of groups too), each whole, as a click selects one; with an Alt-marquee, every + // object it touches, each whole. + if enclosed || whole { let mut touched = vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true); let editable = cx.doc.editable(); touched.retain(|id| editable(*id)); - let ids: Vec<Value> = crate::select::inside(cx, r, touched).into_iter().map(|id| json!(id.0)).collect(); + let picked = if enclosed { crate::select::inside(cx, r, touched) } else { touched }; + let ids: Vec<Value> = picked.into_iter().map(|id| json!(id.0)).collect(); let art = Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": ids })); - return std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, true)).collect(); + return std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, enclosed)).collect(); } // Anchors inside the rect, on editable paths inside isolation mode. let mut sel: Vec<(NodeId, Vec<AnchorRef>)> = vec![]; diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index cb852c5531..6a7be3eccd 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -922,6 +922,27 @@ fn shift_marquee_toggles_anchors() { } } +/// #4890: Alt held at the press makes a Direct Selection marquee Group Selection's for the drag: +/// it selects each path it touches whole (with Shift, toggles it), not the anchors inside it, and +/// leaves the paths it misses; the Group Selection cursor stays until the release. +#[test] +fn an_alt_marquee_selects_the_paths_it_touches_whole() { + let (d, a, _) = two_squares(); + let (s, p) = (Selection::default(), paint()); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + let alt_shift = Mods { shift: true, ..alt }; + for (mods, cmd) in [(alt, "select.set"), (alt_shift, "select.toggle")] { + let mut t = DirectSelectionTool::new(false); + // Round A's top-right corner only; B (300..400) is left out. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0).with_mods(mods)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0).with_mods(mods)); + assert_eq!(t.cursor(&cx, Point::new(250.0, 150.0), mods), Cursor::GroupSelection, "{mods:?}"); + let up = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0).with_mods(mods)); + assert_eq!(up, vec![Action::Exec(cmd.into(), json!({"ids": [a.0]}))], "{mods:?}"); + } +} + /// Group Selection: E pressed during a marquee drag makes it select the objects entirely inside /// it whole, a group's member without its group. Direct Selection does not take E. #[test] From 83c158703a22e4f5033c08a7f035566e48cf7cc6 Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Sat, 31 Oct 2026 21:48:48 +0700 Subject: [PATCH 95/98] Fix #4891: Direct Selection selects and moves an object by its center point Docs: ui-parity and target-app-parity rows for #4890 and #4891. --- crates/tools/src/direct.rs | 28 ++++++++++++++++++++++++++ crates/tools/src/direct/tests.rs | 34 ++++++++++++++++++++++++++++++++ docs/target-app-parity.md | 5 ++++- docs/ui-parity.md | 5 ++++- 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index c1aae51a4e..c519b2e56d 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -38,6 +38,8 @@ //! them all, and the arrow keys nudge them; otherwise the type is selected whole. Dragging an anchor of type on //! a path's path reshapes the path (`text.reshapePath`): the text reflows along it. //! 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 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 @@ -513,6 +515,16 @@ fn nearest_anchor(pd: &PathData, p: Point, tol: f64) -> Option<(usize, usize, Po pd.anchors().map(|(si, ai, a)| (si, ai, a.p)).filter(|(.., a)| a.distance(p) <= tol).min_by(|x, y| x.2.distance(p).total_cmp(&y.2.distance(p))) } +/// The selected object whose centre point (Attributes panel › Show Center), drawn while it is +/// selected, is within `tol` of `p`: (object, where the point is). +fn hit_center(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Point)> { + let (symbols, editable) = (cx.doc.symbol_boxes(), cx.doc.editable()); + cx.selection.objects.iter().copied().filter(|id| editable(*id) && in_scope(cx.doc, cx.isolation, *id)).find_map(|id| { + let c = cx.doc.node(id).filter(|n| n.shows_center())?.geometric_bounds_with(&symbols)?.center(); + (c.distance(p) <= tol).then_some((id, c)) + }) +} + /// An edge of an area type frame under `p`, topmost first: (text, the edge's two anchors). fn hit_frame_edge(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Vec<AnchorRef>)> { anchor_owners(cx, is_area_type).into_iter().find_map(|id| { @@ -742,6 +754,9 @@ impl Tool for DirectSelectionTool { if let Some((id, si, ai, grab)) = hit_anchor(cx, p, point) { return self.press_anchor(cx, ev, p, (id, si, ai, grab)); } + if let Some((id, center)) = hit_center(cx, p, point) { + return self.press_center(cx, ev, p, id, center); + } if let Some((id, anchors)) = hit_frame_edge(cx, p, tol) { self.frame.clear(); return self.press_type_area(cx, id, vec![(id, anchors)], p, ev.mods.shift); @@ -1198,6 +1213,19 @@ impl DirectSelectionTool { } vec![] } + /// Press on the centre point of object `id`: it selects the object whole (Shift toggles it), + /// as a press on its fill does, and a drag moves it held by that point (#4891). + fn press_center(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point, id: NodeId, center: Point) -> Vec<Action> { + self.state = State::MoveObject { start: p, grab: None, hold: center, began: false, deselect: None }; + self.frame.clear(); + 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) { + return vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; + } + vec![] + } fn press_object(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point, tol: f64, mut h: vectorcraft_doc::hit::Hit) -> Vec<Action> { if let Some(part) = cx.doc.node(h.leaf).and_then(|n| compound_part_at(n, p, tol)) { h.ancestry.push(part); diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index 6a7be3eccd..b7892cc2d3 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -943,6 +943,40 @@ fn an_alt_marquee_selects_the_paths_it_touches_whole() { } } +/// #4891: a press on the centre point a selected object shows (Show Center) selects it whole, and +/// a drag from there moves it by the drag as one Move; the square is unfilled, so the press finds +/// no fill there. +#[test] +fn the_centre_point_selects_and_moves_the_object_whole() { + let (mut d, id) = doc_with_rect(); + let n = d.node_mut(id).unwrap(); + n.appearance.items.retain(|i| !matches!(i, vectorcraft_doc::AppearanceItem::Fill(_))); + n.edit_attrs(|a| a.show_center = Some(true)); + let p = paint(); + // One anchor selected: the square shows its centre, (150, 150). + let one = anchors_of(id, &[(0, 0)]); + let mut t = DirectSelectionTool::new(false); + let c = cx(&d, &one, &p); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 151.0, 149.0)), vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 151.0, 149.0)).is_empty()); + // Selected whole: the press keeps the selection and the drag moves the square. + let mut whole = Selection::default(); + whole.set([id]); + let c = ToolContext { smart_guides: false, ..cx(&d, &whole, &p) }; + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)).is_empty()); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 170.0, 140.0)); + let moved = json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(Vec2::new(20.0, -10.0))), "copy": false}); + assert_eq!(a, vec![Action::Begin("Move".into()), Action::Preview("object.transform".into(), moved)]); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 170.0, 140.0)), vec![Action::Commit]); + // Unselected, the centre isn't shown and a press there starts a marquee. + let none = Selection::default(); + let mut t = DirectSelectionTool::new(false); + let c = cx(&d, &none, &p); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)).is_empty()); + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 170.0, 140.0)); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Marquee(_)))); +} + /// Group Selection: E pressed during a marquee drag makes it select the objects entirely inside /// it whole, a group's member without its group. Direct Selection does not take E. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 639e286939..340854a0de 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Arrange Tools add-on; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Direct Selection: Alt-marquee selects whole paths, #4890; the centre point selects and moves its object, #4891; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -704,8 +704,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `path.reshapeSegment`, `object.blend.spine.reshapeSegment`, `path.convertAnchor`, `path.curvatureEdit`, `path.insertAnchor`, `path.moveAnchors`, `path.reshape`, `path.close` and the Pencil, Curvature, Knife, Eraser, Path Eraser, Smooth, Join and Blob Brush commands refuse a number, point or handle given as text, such as "10", and leave the document unchanged; `path.convertAnchor` refuses an `x` without a `y`, or a `y` without an `x`. `path.close` and `path.setHandle` (`independent`), `path.moveAnchors` (`patterns`), the Pencil (`closed`, `keepSelected`, `fill`), Curvature (`closed` and a point's `corner`), `path.curvatureEdit` (`corner`), `path.reshapeSegment` (`perpendicular`) and the Blob Brush (`keepSelected`, `mergeOnlySelection`, `merge`) refuse a value other than true, false or null, such as "true", and leave the document unchanged. Direct Selection's marquee selects the frame anchors of area type that lie inside it, and a click on a frame anchor selects that one; Shift toggles them. The selected frame anchors show filled. A drag of one of them moves all of them, the arrow keys nudge them, and the type reflows at its size (`text.reshapeArea`, which takes `items` to move the anchors of several frames in one step), as [Create text](http://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) and [Selecting objects](http://web.archive.org/web/20211230015528/https://helpx.adobe.com/illustrator/using/selecting-objects.html) describe. When the selection also holds other objects, path anchors or ruler guides, or when another change to the document or the selection follows the pick, the type is selected whole. + 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 as one Move, held by that point (#4891; test `the_centre_point_selects_and_moves_the_object_whole`). - **Group Selection (#2441):** a click picks one path of a compound path (its outline within the pick tolerance, else the innermost path whose area holds the click: the outer shape or a hole), Shift-click adds another, and a click on a selected path adds the compound path, as a click on a group member climbs to its group. A path selected this way moves, rotates and scales on its own; paint still goes to the compound path. With Direct Selection, an Alt-click on a path selected whole adds its group, and each further Alt-click adds the next group up, as Group Selection's clicks do; the climb stops below the layer and its sublayers and at the isolated object. An Alt-drag still copies the selection, and Alt+Shift-click on a segment or the fill still adds or removes the path. Public references: [Select objects](https://web.archive.org/web/20250904012025/https://helpx.adobe.com/illustrator/using/selecting-objects.html) and [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copies). + With Alt held at the press, a Direct Selection marquee is Group Selection's for the drag: it selects every path it touches, whole (Shift toggles them), not the anchors inside it, and the Group Selection pointer stays until the release (#4890; test `an_alt_marquee_selects_the_paths_it_touches_whole`). With Direct Selection, Shift pressed during the drag of a curved segment keeps both of its handles at right angles to the line between its anchors, on the same side and at the same length (`path.reshapeSegment {perpendicular}`). Shift held while any segment is dragged with the Anchor Point tool or the Pen with Alt does the same. With the Anchor Point tool, Shift held at the press on an anchor (or a selected blend's spine point) moves it, handles and all, instead of pulling out handles; Shift kept down keeps the move at 45° steps, and a Shift-click that doesn't move leaves the anchor as it was (`path.moveAnchor`). A selected blend spine's segment dragged with the Anchor Point tool, or a curved one dragged with Direct Selection, bends the same way (`object.blend.spine.reshapeSegment`). - **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). A link toggle behind Corner Radius (the `singleCornerRadius` preference) switches the panels to a field for each corner of a live rectangle: Top Left, Top Right, Bottom Right and Bottom Left. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two sides meet at an angle at an anchor that isn't smooth: curved sides too, trimmed to what the cut leaves of them (#1144). Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply. With 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 it rounds that corner alone; Shift-click toggles it (#1239). While a corner widget is dragged with the Selection tool the bounding box hides, so the rounding shows against the path alone (#1229). A round corner next to a curved side curves smoothly instead of sticking out of the shape (#1547, fixes #1512). 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. @@ -1382,6 +1384,7 @@ dialogs, Preview and Cancel, Zoom to Selection) and `command_sweep`. Commands: ` | Date | Change | Summary | |---|---|---| +| 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. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index add5e8fd75..2ad33fd86a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Arrange Tools add-on; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Direct Selection: an Alt-marquee selects the paths it touches whole, #4890; a click on a selected object's centre point selects it whole and a drag moves it, #4891; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -129,6 +129,8 @@ tools, never checked side by side) isn't captured row by row. | 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) | | 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`) | @@ -377,6 +379,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Direct Selection: with Alt held at the press, a marquee selects every path it touches whole and keeps the Group Selection pointer while dragging (#4890); a click on a selected object's centre point (Show Center) selects the object whole, and a drag from it moves the object (#4891); scores unchanged | | 2026-10-31 | minor | Arrange Tools add-on (a VectorCraft addition): Object › Plug-ins › Arrange Tools; scores and estimates unchanged | | 2026-10-31 | minor | Export for Screens is laid out as Illustrator's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged | | 2026-10-31 | minor | Properties: Quick Actions has Blend Options for one selected blend with any tool, not only the Blend tool, as the blending page describes (#4817); scores unchanged | From 9b407edbbe00176925b3654005ed39567ec70807 Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Sat, 31 Oct 2026 21:59:23 +0700 Subject: [PATCH 96/98] Docs: ROADMAP progress log for 2026-10-31 --- ROADMAP.md | 4 +++- docs/target-app-parity.md | 6 +++--- docs/ui-parity.md | 3 ++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 515e2824d6..3511fe3f23 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,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-10-31 · **Change:** minor (Arrange Tools add-on: Arrange in Grid, Swap Objects, Replace with Top Object, Set Size of Each and Zoom to Selection; headline numbers unchanged; previously: OpenType SVG colour fonts draw and export as vector art; previously: COLR colour fonts (v0 and v1) draw and export as vector art; previously: 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; previously: colour emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; headline numbers unchanged; previously:; Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; ruby (ふりがな) for live text: `type.ruby` sets or 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); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Progress log rows for the Arrange Tools add-on, Export for Screens as the reference app's dialog and the 2026-10-31 community landings; headline numbers 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 @@ -129,6 +129,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log +- 2026-10-31: Export for Screens works as the reference app's dialog: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File (the chosen artboards as one PDF's pages, `pdfs: "single"`) or Multiple Files, iOS and Android rows, format presets in a ☰ menu, and a count of the files the export writes (#4895). About 55 community PRs and fixes landed the same day: Find Font, Replace Font, Outline Stroke and Expand leave hidden and locked art alone (with a warning); DXF export writes LINE and ELLIPSE entities and outlines only the strokes a CAD line can't draw; Blob Brush Angle and Roundness; Shift-constrained Perspective Selection; Grid Repeat Options Preview; a gradient mesh from a file budgeted at about a million quads; width-profile strokes keep their shape zoomed out; `.ai`/EPS import fidelity (area type, envelopes, transparency in dictionaries, character styles); PDF import finds hyphenated and CMap-suffixed font names; Windows renders follow the process's core affinity; Enter or Escape in a floating panel's field no longer reaches the canvas (#4803); Properties shows Blend Options for any selected blend (#4817); Artwork Cleanup finds doubled type (#4835) and joins stacked lines into one object (Join Point Text, #4826). Headline numbers unchanged. - 2026-10-31: Arrange Tools, a built-in add-on (Object › Plug-ins › Arrange Tools): Arrange in Grid lays the selected objects out in rows and columns, a row or a column with gaps, from the selection's top-left or centred on the artboard; Swap Objects swaps two objects' places (and, with Swap Objects and Sizes, their sizes); Replace with Top Object puts a copy of the topmost selected object in place of each of the others, kept, stretched or fitted in proportion; Set Size of Each gives every selected object an exact width and/or height about a reference point; Zoom to Selection fits the view to the selection. Each is one undo step with a previewed dialog where it has options. A VectorCraft addition: headline numbers unchanged. - 2026-10-30: Gradient panel presets: a Presets menu applies one of the built-in gradient libraries' presets (grouped by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the current gradient as a preset, kept with the preferences, and deletes saved ones (`gradient.presets.list/apply/save/delete`). - 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged. @@ -203,6 +204,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Progress log row for Export for Screens laid out as the reference app's (#4895) and the 2026-10-31 community landings; headline numbers unchanged. | | 2026-10-31 | minor | Progress log row for the Arrange Tools add-on; headline numbers unchanged. | | 2026-10-29 | minor | Progress log row for the Linux Flatpak clipboard's core Wayland protocol text fallback; headline numbers unchanged. | | 2026-10-29 | minor | Progress log row for the Linux clipboard retry backoff; headline numbers unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 340854a0de..dcf21aa418 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Direct Selection: Alt-marquee selects whole paths, #4890; the centre point selects and moves its object, #4891; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Export for Screens laid out as the reference app's, with Export PDFs As and sub-folders by scale or format) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -19,7 +19,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| -| 2026-10-31 | minor | Export for Screens is laid out as Illustrator's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged. | +| 2026-10-31 | minor | Export for Screens is laid out as the reference app's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged. | | 2026-10-31 | minor | Artwork Cleanup add-on: Join Point Text (`addon.cleanup.joinText`) joins stacked one-line point type into one object, a paragraph per line keeping its baseline (#4826); scores unchanged. | | 2026-10-31 | minor | Width tool: a stroke with a width profile keeps its shape while the view zooms out; the minimum visible width goes to its widest point instead of scaling the whole profile (#995); scores unchanged. | | 2026-10-31 | minor | Patterns: Grid Repeat Options has a Preview checkbox, as the public grid options page lists (#2651); scores unchanged. | @@ -543,7 +543,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Batch…: Override Save and Override Export name each file with the extension of the format the step writes, however the step spells the format (`.svg` and `SVG` give `.svg`, `png8` gives `.png`), in `action.batch`. - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. - - Export for Screens is laid out as Illustrator'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, 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`. + - 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, 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; every command, like the rest, is drivable over MCP and the CLI. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2ad33fd86a..df96d05276 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Direct Selection: an Alt-marquee selects the paths it touches whole, #4890; a click on a selected object's centre point selects it whole and a drag moves it, #4891; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Export for Screens laid out as the reference app's) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -379,6 +379,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Export for Screens is laid out as the reference app's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged | | 2026-10-31 | minor | Direct Selection: with Alt held at the press, a marquee selects every path it touches whole and keeps the Group Selection pointer while dragging (#4890); a click on a selected object's centre point (Show Center) selects the object whole, and a drag from it moves the object (#4891); scores unchanged | | 2026-10-31 | minor | Arrange Tools add-on (a VectorCraft addition): Object › Plug-ins › Arrange Tools; scores and estimates unchanged | | 2026-10-31 | minor | Export for Screens is laid out as Illustrator's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged | From cd2b5f77a44695d555ffbea45c086eabfbfb33ac Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Sat, 31 Oct 2026 22:07:50 +0700 Subject: [PATCH 97/98] Fix #4882: Rasterize to CMYK makes a CMYK image --- crates/engine/src/cmd/menucmds.rs | 90 +++++++++++++++++++---- crates/engine/src/cmd/rasterfx.rs | 4 +- crates/engine/src/tests/rastersettings.rs | 46 ++++++++++++ docs/target-app-parity.md | 4 +- 4 files changed, 126 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index cd8e0c27c4..7a93ac6176 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -115,7 +115,7 @@ pub fn specs() -> Vec<CommandSpec> { "Rasterize…", ["Object"], None, - "{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}", + "{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}", has_selection, rasterize ), @@ -513,15 +513,15 @@ pub(crate) fn unique_key(d: &vectorcraft_doc::Document, stem: &str) -> String { } } -/// `doc` rendered over `region` with `opts` as a PNG in colour model `color`, with the note that an -/// image in it couldn't be decoded ([`super::rasterfx::undecoded_warning`]). -fn render_png( +/// `doc` rendered over `region` with `opts` in colour model `color` (premultiplied RGBA), with the +/// note that an image in it couldn't be decoded ([`super::rasterfx::undecoded_warning`]). +fn render_pixels( doc: &vectorcraft_doc::Document, region: Rect, scale: f64, opts: &vectorcraft_render::RenderOptions, color: RasterColorModel, -) -> Result<(Vec<u8>, u32, u32, Option<&'static str>)> { +) -> Result<(vectorcraft_render::Rendered, Option<&'static str>)> { if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > 65535.0 || region.height() * scale > 65535.0 { return Err(EngineError::Other("image would be too large; lower the resolution".into())); } @@ -530,13 +530,57 @@ fn render_png( let mut img = r.render_region_with(doc, region, scale, opts); let warning = super::rasterfx::undecoded_warning(failed); RasterEffectsSettings { color_model: color, ..Default::default() }.finish_pixels(&mut img.pixels); + Ok((img, warning)) +} + +/// [`render_pixels`] as a PNG, with its width and height. +fn render_png( + doc: &vectorcraft_doc::Document, + region: Rect, + scale: f64, + opts: &vectorcraft_render::RenderOptions, + color: RasterColorModel, +) -> Result<(Vec<u8>, u32, u32, Option<&'static str>)> { + let (img, warning) = render_pixels(doc, region, scale, opts, color)?; Ok((img.to_png().map_err(EngineError::Other)?, img.width, img.height, warning)) } +/// `doc` over `region` as a CMYK image of its ink amounts (as CMYK exports draw them), with the +/// transparency of `img` (the same art in screen colours) as a greyscale PNG when it has any: a +/// CMYK image keeps no alpha of its own. +fn cmyk_raster( + doc: &vectorcraft_doc::Document, + region: Rect, + scale: f64, + opts: &vectorcraft_render::RenderOptions, + img: &vectorcraft_render::Rendered, +) -> Result<(vectorcraft_doc::ImageBlob, Option<Vec<u8>>)> { + let inks = vectorcraft_render::Renderer::new().render_region_inks(doc, region, scale, opts); + let alpha: Vec<u8> = img.pixels.as_chunks::<4>().0.iter().map(|p| p[3]).collect(); + // Partly covered pixels carry that much ink: divided back out, as straight colour is, since + // the mask applies the coverage. + let straight = |v: u8, a: u8| if a == 0 { 0 } else { ((u32::from(v) * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8 }; + let data = inks.as_chunks::<4>().0.iter().zip(&alpha).flat_map(|(p, &a)| p.map(|v| straight(v, a))).collect(); + let blob = vectorcraft_doc::cmyk::Inks::new(img.width, img.height, data) + .and_then(|i| vectorcraft_doc::ImageBlob::cmyk_tiff(&i)) + .ok_or_else(|| EngineError::Other("the CMYK image couldn't be written".into()))?; + if alpha.iter().all(|&a| a == 255) { + return Ok((blob, None)); + } + let mask = image::GrayImage::from_raw(img.width, img.height, alpha) + .ok_or_else(|| EngineError::Other("the image's transparency doesn't fit it".into()))?; + let mut png = Vec::new(); + mask.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| EngineError::Other(e.to_string()))?; + Ok((blob, Some(png))) +} + /// The image Object › Rasterize makes of some art, before it goes in a document /// ([`rasterize_art`]). pub(crate) struct Rasterized { - png: Vec<u8>, + /// A PNG, or a CMYK image of the art's inks ([`cmyk_raster`]). + blob: vectorcraft_doc::ImageBlob, + /// A CMYK image's transparency, a greyscale PNG: it becomes the image's opacity mask. + alpha: Option<Vec<u8>>, width: u32, height: u32, /// Image pixels → document. @@ -551,11 +595,18 @@ impl Rasterized { /// The image as a new object of `d` (its pixels in `d.images`), in a clip group with the art's /// outline when it has one. pub(crate) fn into_node(self, d: &mut vectorcraft_doc::Document) -> Node { - let key = unique_key(d, "raster"); - d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", self.png)); - let id = d.alloc_id(); - let image = ImageObject { key, width: self.width, height: self.height, xf: self.xf, link: None, placement: Default::default(), pixels: None }; - let node = Node::new(id, NodeKind::Image(Box::new(image))); + let image = |d: &mut vectorcraft_doc::Document, blob: vectorcraft_doc::ImageBlob| { + let key = unique_key(d, "raster"); + d.images.insert(key.clone(), blob); + let image = + ImageObject { key, width: self.width, height: self.height, xf: self.xf, link: None, placement: Default::default(), pixels: None }; + Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))) + }; + let mut node = image(d, self.blob); + if let Some(png) = self.alpha { + let art = image(d, vectorcraft_doc::ImageBlob::new("image/png", png)); + node.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true))); + } match self.clip { Some((path, rule)) => super::rasterfx::clip_group(d, path, rule, node), None => node, @@ -565,8 +616,9 @@ impl Rasterized { /// Object › Rasterize's image of `nodes` (drawn also where hidden) with the params `p` of command /// `cmd`, each defaulting to `doc`'s raster effects settings: over the bounds of what the art draws -/// ([`vectorcraft_render::drawn_bounds`]) and the room around them, in whole pixels. -pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec<Node>, p: &Value, cmd: &str) -> Result<Rasterized> { +/// ([`vectorcraft_render::drawn_bounds`]) and the room around them, in whole pixels. With `inks`, +/// a CMYK document's colour model makes a CMYK image ([`cmyk_raster`]); otherwise images are PNGs. +pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec<Node>, p: &Value, cmd: &str, inks: bool) -> Result<Rasterized> { check_numbers(p, &["ppi"], cmd)?; let ppi = f64_or(p, "ppi", doc.raster_effects_ppi); if !(1.0..=2400.0).contains(&ppi) { @@ -630,9 +682,15 @@ pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec<Node>, p .flatten() .map(|(bp, rule)| (PathData::from_bezpath(&bp), rule)); let tmp = isolated_doc(doc, nodes); - let (png, width, height, warning) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?; + let opts = raster.render_options(RasterFormat::Png); + let (img, warning) = render_pixels(&tmp, region, scale, &opts, look.color_model)?; + let (blob, alpha) = if inks && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk && look.color_model == RasterColorModel::Document { + cmyk_raster(&tmp, region, scale, &opts, &img)? + } else { + (vectorcraft_doc::ImageBlob::new("image/png", img.to_png().map_err(EngineError::Other)?), None) + }; let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); - Ok(Rasterized { png, width, height, xf, clip, warning }) + Ok(Rasterized { blob, alpha, width: img.width, height: img.height, xf, clip, warning }) } fn rasterize(s: &mut Session, p: &Value) -> Result<Value> { @@ -640,7 +698,7 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> { let roots = selected_roots(s)?; let st = s.doc()?; let nodes: Vec<Node> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).collect(); - let image = rasterize_art(&st.doc, nodes, p, C)?; + let image = rasterize_art(&st.doc, nodes, p, C, true)?; let (w, h, warning) = (image.width, image.height, image.warning); if roots.is_empty() { return Err(bad(C, "nothing selected")); diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 43ec3e40d7..938d2643a4 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -351,7 +351,9 @@ fn rasterize_effect_image(d: &mut Document, n: &Node) -> Option<Node> { whole.appearance.effects.remove(at); (whole.opacity, whole.blend) = (1.0, Default::default()); whole.extras_mut().mask = None; - let image = super::menucmds::rasterize_art(d, vec![whole], &p, "object.rasterize").ok()?; + // shortcut: expanded Rasterize effects stay RGB, as the object keeps its own opacity mask and the + // image's transparency would need one too; upgrade when CMYK images carry alpha. + let image = super::menucmds::rasterize_art(d, vec![whole], &p, "object.rasterize", false).ok()?; Some(image.into_node(d)) } diff --git a/crates/engine/src/tests/rastersettings.rs b/crates/engine/src/tests/rastersettings.rs index b3f1cf50b6..2110e611ea 100644 --- a/crates/engine/src/tests/rastersettings.rs +++ b/crates/engine/src/tests/rastersettings.rs @@ -409,3 +409,49 @@ fn a_gradient_exports_at_the_resolution_the_file_opens_with() { assert_eq!(sizes, [(10, 10)], "saved as {saved}"); } } + +/// Rasterize in a CMYK document's colour model makes a CMYK image of the art's inks (#4882): the +/// image says CMYK, CMYK colours keep their numbers, a CMYK export keeps them, and a transparent +/// background becomes the image's opacity mask. +#[test] +fn rasterize_to_cmyk_makes_a_cmyk_image() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "colorMode": "cmyk"})).unwrap(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()); + s.execute("paint.setFill", &json!({"color": {"c": 0.2, "m": 0.4, "y": 0.6, "k": 0.1}, "ids": [r.0]})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true, "ids": [r.0]})).unwrap(); + let want = [51u8, 102, 153, 26]; + let near = |p: &[u8]| p.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 1); + let rasterize = |s: &mut Session, background: &str| { + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let p = json!({"ppi": 72, "colorModel": "cmyk", "background": background, "addAround": 5}); + let img = id(&s.execute("object.rasterize", &p).unwrap()); + assert_eq!(s.execute("image.info", &json!({"id": img.0})).unwrap()["colorMode"], "CMYK"); + let d = &s.doc().unwrap().doc; + let n = d.node(img).unwrap().clone(); + let NodeKind::Image(im) = &n.kind else { panic!("an image") }; + let inks = d.images[&im.key].cmyk().expect("a CMYK image"); + assert_eq!((inks.width, inks.height), (50, 50)); + let at = |x: u32, y: u32| inks.data[((y * 50 + x) * 4) as usize..][..4].to_vec(); + assert!(near(&at(25, 25)), "the fill's inks: {:?}", at(25, 25)); + assert_eq!(at(1, 1), [0; 4], "no ink around the art"); + n + }; + let opaque = rasterize(&mut s, "white"); + assert!(opaque.extras().mask.is_none(), "opaque: no mask"); + // A CMYK TIFF export keeps the image's inks. + let tiff = s.execute("document.export", &json!({"format": "tiff", "colorModel": "cmyk", "ppi": 72})).unwrap(); + let tiff = vectorcraft_format::base64_decode(tiff["dataBase64"].as_str().unwrap()).unwrap(); + let inks = vectorcraft_doc::ImageBlob::new("image/tiff", tiff).cmyk().expect("a CMYK TIFF"); + let at = ((30 * inks.width + 30) * 4) as usize; + assert!(near(&inks.data[at..at + 4]), "exported: {:?}", &inks.data[at..at + 4]); + // Transparent: the art's coverage is the image's opacity mask. + s.execute("edit.undo", &json!({})).unwrap(); + let masked = rasterize(&mut s, "transparent"); + let mask = masked.extras().mask.as_deref().expect("an opacity mask").clone(); + let d = &s.doc().unwrap().doc; + let NodeKind::Image(m) = &mask.art.kind else { panic!("the mask is an image") }; + let alpha = image::load_from_memory(&d.images[&m.key].bytes).unwrap().to_luma8(); + assert_eq!((alpha.get_pixel(1, 1).0, alpha.get_pixel(25, 25).0), ([0], [255])); + assert!(mask.clip); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index dcf21aa418..3daa404e82 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Export for Screens laid out as the reference app's, with Export PDFs As and sub-folders by scale or format) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Object › Rasterize with the CMYK colour model makes a CMYK image, #4882; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -19,6 +19,8 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Object › Rasterize in a CMYK document with its CMYK colour model makes a CMYK image of the art's ink amounts (as CMYK exports draw them), so the Properties panel says CMYK and CMYK exports keep its inks; a transparent background becomes the image's opacity mask (#4882); scores unchanged. | +| 2026-10-31 | minor | Export for Screens is laid out as Illustrator's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged. | | 2026-10-31 | minor | Export for Screens is laid out as the reference app's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged. | | 2026-10-31 | minor | Artwork Cleanup add-on: Join Point Text (`addon.cleanup.joinText`) joins stacked one-line point type into one object, a paragraph per line keeping its baseline (#4826); scores unchanged. | | 2026-10-31 | minor | Width tool: a stroke with a width profile keeps its shape while the view zooms out; the minimum visible width goes to its widest point instead of scaling the whole profile (#995); scores unchanged. | From 06043139e644e71d2f57353ee74ace7fe5b02362 Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Sat, 31 Oct 2026 22:07:51 +0700 Subject: [PATCH 98/98] Fix #4893: New Document saves a preset after More Settings --- crates/ui-egui/src/dialogs/new_document.rs | 75 +++++++++++++++++++--- docs/target-app-parity.md | 3 +- docs/ui-parity.md | 3 +- 3 files changed, 70 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/dialogs/new_document.rs b/crates/ui-egui/src/dialogs/new_document.rs index dde5c83fbd..5d53ebe73e 100644 --- a/crates/ui-egui/src/dialogs/new_document.rs +++ b/crates/ui-egui/src/dialogs/new_document.rs @@ -330,15 +330,7 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut ui.add_space(8.0); ui.horizontal(|ui| { form::text(ui, d, "name", DETAILS - 50.0); - if widgets::icon_button(ui, "save", tl!("Save Preset"), d.bool(SAVING), 26.0).clicked() { - let saving = !d.bool(SAVING); - d.fields.insert(SAVING.into(), json!(saving)); - if saving && d.str("presetName").is_empty() { - let template = "Preset {number}"; - let name = crate::i18n::fmt(tl!(template), &[("number", &(app.session.prefs.new_doc_presets.len() + 1).to_string())]); - d.fields.insert("presetName".into(), json!(name)); - } - } + save_button(app, ui, d); }); if d.bool(SAVING) { save_row(app, ui, d); @@ -388,6 +380,19 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut }); } +/// The Save Preset button beside the name: it shows or hides [`save_row`]. +fn save_button(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + if widgets::icon_button(ui, "save", tl!("Save Preset"), d.bool(SAVING), 26.0).clicked() { + let saving = !d.bool(SAVING); + d.fields.insert(SAVING.into(), json!(saving)); + if saving && d.str("presetName").is_empty() { + let template = "Preset {number}"; + let name = crate::i18n::fmt(tl!(template), &[("number", &(app.session.prefs.new_doc_presets.len() + 1).to_string())]); + d.fields.insert("presetName".into(), json!(name)); + } + } +} + /// 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); @@ -520,7 +525,14 @@ fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.add_space(12.0); widgets::label_row(ui, tl!("Name:"), L, |ui| { form::text(ui, &mut d, "name", 300.0); + save_button(app, ui, &mut d); }); + if d.bool(SAVING) { + // Under the name, in the fields' column. + widgets::label_row(ui, "", L, |ui| { + ui.vertical(|ui| save_row(app, ui, &mut d)); + }); + } // Profile: the preset categories (the tabs); Size: the profile's presets. let profiles: Vec<&str> = newdoc::CATEGORIES.iter().map(|c| c.0).collect(); let profile = profiles.iter().find(|p| p.eq_ignore_ascii_case(&d.str("category"))).copied().unwrap_or("[Custom]"); @@ -894,4 +906,49 @@ mod tests { crate::dialogs::confirm(&mut app).unwrap(); assert_eq!(app.session.prefs.recent_new_docs[0].name, "Zine"); } + + /// Save Preset works in More Settings too (#4893), saving its settings: the artboard layout + /// only More Settings shows among them. + #[test] + fn more_settings_saves_a_preset_of_its_settings() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.newDialog", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().kind = super::MORE.into(); + set(&mut app, "artboards", json!(4)); + set(&mut app, "artboardLayout", json!({"layout": "column", "spacing": 30})); + set(&mut app, super::SAVING, json!(true)); + set(&mut app, "presetName", json!("Strip")); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut time = 0.0; + let mut run = |app: &mut VectorcraftApp, events| { + 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, events, 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, vec![]); + let out = run(&mut app, vec![]); + fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect().center())), + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut texts = vec![]; + out.shapes.iter().for_each(|c| walk(&c.shape, &mut texts)); + assert!(texts.iter().any(|(t, _)| t == "Preset Name"), "the preset's name field shows"); + let at = texts.iter().find(|(t, _)| t == "Save").map(|(_, p)| *p).expect("a Save button"); + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + run(&mut app, vec![egui::Event::PointerMoved(at), press(true)]); + run(&mut app, vec![press(false)]); + let saved = app.session.prefs.new_doc_presets.iter().find(|p| p.name == "Strip").expect("saved"); + assert_eq!(saved.artboards, 4); + assert_eq!((saved.layout, saved.spacing), (super::ArtboardLayout::Column, 30.0)); + assert_eq!((field(&app, "category"), field(&app, "preset"), field(&app, super::SAVING)), (json!("Saved"), json!("Strip"), json!(false))); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, super::MORE, "More Settings stays open"); + } } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3daa404e82..e7e762140a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Object › Rasterize with the CMYK colour model makes a CMYK image, #4882; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (New Document: Save Preset in More Settings, #4893; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -19,6 +19,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | New Document: More Settings has the Save Preset button beside the name too, saving its settings (artboard layout included) as a preset (#4893); scores unchanged. | | 2026-10-31 | minor | Object › Rasterize in a CMYK document with its CMYK colour model makes a CMYK image of the art's ink amounts (as CMYK exports draw them), so the Properties panel says CMYK and CMYK exports keep its inks; a transparent background becomes the image's opacity mask (#4882); scores unchanged. | | 2026-10-31 | minor | Export for Screens is laid out as Illustrator's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged. | | 2026-10-31 | minor | Export for Screens is laid out as the reference app's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index df96d05276..3296cb3a5e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Export for Screens laid out as the reference app's) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (New Document: Save Preset in More Settings, #4893; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -379,6 +379,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | New Document: More Settings has Save Preset beside the name, as Preset Details does, and saves its settings (#4893); scores and estimates unchanged | | 2026-10-31 | minor | Export for Screens is laid out as the reference app's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged | | 2026-10-31 | minor | Direct Selection: with Alt held at the press, a marquee selects every path it touches whole and keeps the Group Selection pointer while dragging (#4890); a click on a selected object's centre point (Show Center) selects the object whole, and a drag from it moves the object (#4891); scores unchanged | | 2026-10-31 | minor | Arrange Tools add-on (a VectorCraft addition): Object › Plug-ins › Arrange Tools; scores and estimates unchanged |